canopycms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. package/package.json +1 -1
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Loader, Tooltip } from '@mantine/core';
4
+ import { IconRefresh } from '@tabler/icons-react';
5
+ import { EDITOR_ACTIONS } from '../copy.js';
6
+ import { GETTING_LATEST_CHANGES, SLOW_NOTICE, WAITING_NOTICE, } from '../base-update-wait.js';
7
+ const TOOLTIP_EVENTS = { hover: true, focus: true, touch: true };
8
+ /** The Tooltip's text: why it waits, why it can't act, or what it does. */
9
+ export function getLatestChangesReason(control) {
10
+ if (control.requesting || control.wait !== 'none') {
11
+ return control.wait === 'slow' ? SLOW_NOTICE : WAITING_NOTICE;
12
+ }
13
+ return control.blockedReason ?? control.tooltip;
14
+ }
15
+ /**
16
+ * "Get latest changes", or "Getting latest changes…" with a spinner while the request stands. It
17
+ * takes the wider label's width either way, so the bar never shifts. It can't act while it works
18
+ * or while something blocks it, and stays focusable (`aria-disabled`) with its Tooltip saying why.
19
+ * Only a block dims it: the working label is progress, read at full contrast.
20
+ */
21
+ export function GetLatestChangesButton({ control, buttonRef, testId = 'get-latest-changes-button', }) {
22
+ const working = control.requesting || control.wait !== 'none';
23
+ const blocked = working || !!control.blockedReason;
24
+ const tooltip = getLatestChangesReason(control);
25
+ return (_jsx(Tooltip, { label: tooltip, events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { ref: buttonRef, size: "sm", variant: "default", style: { flexShrink: 0 }, leftSection: working ? (_jsx(Loader, { size: 14, "aria-hidden": "true" })) : (_jsx(IconRefresh, { size: 14, "aria-hidden": "true" })), onClick: () => {
26
+ if (!blocked)
27
+ control.onRequest();
28
+ }, "data-disabled": blocked && !working ? true : undefined, "aria-disabled": blocked ? true : undefined, "data-working": working || undefined, "data-testid": testId, children: _jsxs("span", { style: { display: 'inline-grid' }, children: [_jsx("span", { style: { gridArea: '1 / 1', visibility: working ? 'hidden' : undefined }, children: EDITOR_ACTIONS.getLatestChanges }), _jsx("span", { style: { gridArea: '1 / 1', visibility: working ? undefined : 'hidden' }, children: GETTING_LATEST_CHANGES })] }) }) }));
29
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What a release came to: released (or already gone), or refused with a message and its tone,
3
+ * `warning` where nothing is lost yet or the outcome is unknown.
4
+ */
5
+ export type ReleaseOutcome = {
6
+ released: true;
7
+ } | {
8
+ error: string;
9
+ tone: 'warning' | 'error';
10
+ };
11
+ /**
12
+ * An admin's way out of a branch held because its conflict record was set aside
13
+ * (api/merge-conflicts.ts `releaseHold`), behind a confirm that says what is lost. A refusal shows
14
+ * in the confirm, which stays open; a release closes it.
15
+ */
16
+ export declare function ReleaseHoldButton({ branchLabel, onRelease, }: {
17
+ /** The branch as the bar names it, which the confirm names. */
18
+ branchLabel?: string;
19
+ onRelease: () => Promise<ReleaseOutcome>;
20
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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,93 @@
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; and where your saved value holds another item at the key of one your version
69
+ * lacks.
70
+ */
71
+ latestPlaceable: boolean;
72
+ /** Your saved value holds another item at the key of the one your version lacks here. */
73
+ otherItemHere?: true;
74
+ /**
75
+ * Long text found again by region from its three versions: the text the merge left, and its
76
+ * regions, each holding your lines there. Absent when the text is one whole value.
77
+ */
78
+ text?: {
79
+ merged: string;
80
+ regions: TextConflictRegion[];
81
+ };
82
+ }
83
+ export interface EntryConflicts {
84
+ fields: FieldConflict[];
85
+ /** Versions the API couldn't show, so a field's cards may show less. */
86
+ incomplete: boolean;
87
+ }
88
+ /**
89
+ * The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
90
+ * md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
91
+ */
92
+ export declare function buildEntryConflicts(data: EntryConflictData, fields: readonly FieldConfig[], bodyField: string | undefined, referenceTitle?: (id: string) => string | undefined, saved?: FormValue): EntryConflicts;
93
+ export {};