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.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { Button, Text } from '@mantine/core';
5
+ import { EDITOR_ACTIONS } from '../copy.js';
6
+ import { releaseHoldConsequences } from '../conflict-notice.js';
7
+ import { ConfirmDeleteModal } from './ConfirmDeleteModal.js';
8
+ /**
9
+ * An admin's way out of a branch held because its conflict record was set aside
10
+ * (api/merge-conflicts.ts `releaseHold`), behind a confirm that says what is lost. A refusal shows
11
+ * in the confirm, which stays open; a release closes it.
12
+ */
13
+ export function ReleaseHoldButton({ branchLabel, onRelease, }) {
14
+ const [open, setOpen] = useState(false);
15
+ const [releasing, setReleasing] = useState(false);
16
+ const [refusal, setRefusal] = useState();
17
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { size: "compact-sm", variant: "default", style: { flexShrink: 0 }, onClick: () => {
18
+ setRefusal(undefined);
19
+ setOpen(true);
20
+ }, "data-testid": "release-hold", children: EDITOR_ACTIONS.releaseHold }), _jsx(ConfirmDeleteModal, { isOpen: open, title: branchLabel ? `Release the hold on "${branchLabel}"?` : "Release this branch's hold?", message: releaseHoldConsequences, confirmLabel: EDITOR_ACTIONS.releaseHold, loading: releasing, onClose: () => setOpen(false), onConfirm: async () => {
21
+ setReleasing(true);
22
+ setRefusal(undefined);
23
+ try {
24
+ const outcome = await onRelease();
25
+ if ('released' in outcome)
26
+ setOpen(false);
27
+ else
28
+ setRefusal(outcome);
29
+ }
30
+ finally {
31
+ setReleasing(false);
32
+ }
33
+ }, children: refusal && (_jsx(Text, { size: "sm", role: "alert", c: refusal.tone === 'warning'
34
+ ? 'var(--mantine-color-yellow-text)'
35
+ : 'var(--mantine-color-red-text)', "data-testid": "release-hold-refusal", children: refusal.error })) })] }));
36
+ }
@@ -6,5 +6,5 @@ import { Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
6
6
  export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', details, }) {
7
7
  const [detailsOpen, setDetailsOpen] = useState(false);
8
8
  const detailsId = useId();
9
- return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
9
+ return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, style: { flexShrink: 0 }, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
10
10
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * How the "Show changes" drawer shows a value and a text change, shared by its change cards and its
3
+ * conflict cards: marked text, values as the form would show them, and the drawer's reasoned button.
4
+ */
5
+ import React from 'react';
6
+ import type { DiffPart, DisplayValue, TextDiff } from '../entry-diff.js';
7
+ export declare const TEXT_STYLE: React.CSSProperties;
8
+ export declare const plural: (count: number, one: string, many: string) => string;
9
+ export declare const ordinal: (n: number) => string;
10
+ export type Side = 'before' | 'after';
11
+ /**
12
+ * One side's text with its runs marked by a strike or an underline and by words, never colour
13
+ * alone. A line mostly changed says so once, up front, instead of run by run.
14
+ */
15
+ export declare const MarkedText: React.FC<{
16
+ parts: readonly DiffPart[];
17
+ side: Side;
18
+ unit: 'line' | 'text';
19
+ }>;
20
+ /** A value as the form would show it: titles for references, file names for images. */
21
+ export declare const Value: React.FC<{
22
+ value: DisplayValue;
23
+ alt?: {
24
+ text: TextDiff;
25
+ side: Side;
26
+ };
27
+ }>;
28
+ /**
29
+ * A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
30
+ * place the editor shows a date absolute first and relative after. A day has no relative form: it
31
+ * may be a future event, and a past one is not hours ago.
32
+ */
33
+ export declare const DateValue: React.FC<{
34
+ value: Extract<DisplayValue, {
35
+ kind: 'date';
36
+ }>;
37
+ text?: React.ReactNode;
38
+ }>;
39
+ /**
40
+ * A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
41
+ * why (`data-disabled` keeps the Tooltip working on focus and touch).
42
+ */
43
+ export declare const ReasonedButton: React.FC<{
44
+ reason?: string;
45
+ onClick: () => void;
46
+ children: React.ReactNode;
47
+ danger?: boolean;
48
+ loading?: boolean;
49
+ 'aria-label'?: string;
50
+ 'data-testid'?: string;
51
+ }>;
@@ -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
+ }