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
@@ -187,12 +187,19 @@ export function useDraftManager(options) {
187
187
  //
188
188
  // 3. The comparison is `fast-deep-equal`, so key insertion order of a
189
189
  // rehydrated draft does not make it dirty.
190
- const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
191
- if (entriesKnown && !entryById.has(id))
192
- return false;
193
- const baseline = loadedValues[id] ?? verifiedBaselines[id];
194
- return baseline === undefined || !equal(drafts[id], baseline);
195
- }), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
190
+ //
191
+ // 4. An entry with a conflict choice to send counts, draft or not (`pendingIds`).
192
+ const pendingIds = options.pendingIds;
193
+ const dirtyIds = useMemo(() => {
194
+ const dirty = Object.keys(drafts).filter((id) => {
195
+ if (entriesKnown && !entryById.has(id))
196
+ return false;
197
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
198
+ return baseline === undefined || !equal(drafts[id], baseline);
199
+ });
200
+ const pending = [...(pendingIds ?? [])].filter((id) => !dirty.includes(id) && (!entriesKnown || entryById.has(id)));
201
+ return pending.length > 0 ? [...dirty, ...pending] : dirty;
202
+ }, [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById, pendingIds]);
196
203
  const modifiedCount = dirtyIds.length;
197
204
  const editedFiles = useMemo(() => dirtyIds
198
205
  .map((id) => {
@@ -963,7 +970,10 @@ export function useDraftManager(options) {
963
970
  */
964
971
  const saveOneOfAll = async (entry, branch) => {
965
972
  const id = entry.contentId;
966
- const sent = latestRef.current.drafts[id];
973
+ const sent = latestRef.current.drafts[id] ??
974
+ (latestOptionsRef.current.pendingIds?.has(id)
975
+ ? latestRef.current.loadedValues[id]
976
+ : undefined);
967
977
  if (sent === undefined)
968
978
  return undefined;
969
979
  const onBranch = () => latestOptionsRef.current.branchName === branch;
@@ -1137,7 +1147,9 @@ export function useDraftManager(options) {
1137
1147
  // Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
1138
1148
  const { drafts: remaining } = latestRef.current;
1139
1149
  const settled = settledDuringRunRef.current;
1140
- result.failed = result.failed.filter((failure) => failure.contentId in remaining && !settled.has(failure.contentId));
1150
+ const pendingNow = latestOptionsRef.current.pendingIds;
1151
+ result.failed = result.failed.filter((failure) => (failure.contentId in remaining || !!pendingNow?.has(failure.contentId)) &&
1152
+ !settled.has(failure.contentId));
1141
1153
  result.total = result.saved.length + result.failed.length;
1142
1154
  if (result.saved.length > 0)
1143
1155
  latestOptionsRef.current.onSaved?.();
@@ -1351,6 +1363,8 @@ export function useDraftManager(options) {
1351
1363
  if (!entry)
1352
1364
  return false;
1353
1365
  const id = entry.contentId;
1366
+ if (pendingIds?.has(id))
1367
+ return true;
1354
1368
  if (!drafts[id])
1355
1369
  return false;
1356
1370
  const baseline = loadedValues[id] ?? verifiedBaselines[id];
@@ -1359,6 +1373,8 @@ export function useDraftManager(options) {
1359
1373
  const isSelectedDirty = () => {
1360
1374
  if (!currentId)
1361
1375
  return false;
1376
+ if (pendingIds?.has(currentId))
1377
+ return true;
1362
1378
  if (!drafts[currentId])
1363
1379
  return false;
1364
1380
  return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
@@ -1,3 +1,4 @@
1
+ import type { ResolvedFieldConflict } from '../../merge-conflicts.js';
1
2
  import type { ApiErrorCode } from '../../api/types.js';
2
3
  import type { EditorEntry, EditorCollection } from '../Editor.js';
3
4
  import type { LogicalPath } from '../../paths/types.js';
@@ -62,6 +63,14 @@ export interface UseEntryManagerOptions {
62
63
  * refreshes the entries, so what reads the same branch state reloads here.
63
64
  */
64
65
  onContentRefresh?: (branch: string) => void;
66
+ /**
67
+ * The conflicts a save of an entry resolves (useConflictChoices.ts): `pending` names them as the
68
+ * save sends them, and `onSaved` hears which the save sent and which the server cleared.
69
+ */
70
+ conflictResolutions?: {
71
+ pending: (branch: string, contentId: string) => ResolvedFieldConflict[];
72
+ onSaved: (branch: string, contentId: string, sent: ResolvedFieldConflict[], cleared: ResolvedFieldConflict[]) => void;
73
+ };
65
74
  }
66
75
  export interface UseEntryManagerReturn {
67
76
  selectedPath: string;
@@ -339,15 +339,20 @@ export function useEntryManager(options) {
339
339
  if (expectedVersion === undefined) {
340
340
  throw new SaveApiError(409, "This entry hasn't finished loading, so it can't be saved yet. Reload it and try again (your unsaved edits will be lost).");
341
341
  }
342
+ const resolving = options.conflictResolutions?.pending(requestBranch, entry.contentId) ?? [];
342
343
  const writeBody = {
343
344
  ...payload,
344
345
  expectedVersion,
346
+ ...(resolving.length > 0 && { resolvedConflicts: resolving }),
345
347
  };
346
348
  const result = await apiClient.content.write(writeParams, writeBody);
347
349
  if (isGatewayFailure(result))
348
350
  throw new UnansweredError();
349
351
  if (!result.ok)
350
352
  throw new SaveApiError(result.status, result.error, result.fieldErrors, result.code);
353
+ if (resolving.length > 0) {
354
+ options.conflictResolutions?.onSaved(requestBranch, entry.contentId, resolving, result.data?.resolvedConflicts ?? []);
355
+ }
351
356
  // Update stored version token from write response
352
357
  if (typeof result.data?.version === 'number') {
353
358
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * An admin's release of a branch held as its record of conflicts was set aside
3
+ * (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
4
+ * again, as the release may have landed.
5
+ */
6
+ import type { ApiClient } from './context/index.js';
7
+ import type { ReleaseOutcome } from './components/ReleaseHoldButton.js';
8
+ /** A release's outcome, and on release whether it found the branch held, when that is known. */
9
+ export type ReleaseResult = ReleaseOutcome & {
10
+ wasHeld?: boolean;
11
+ };
12
+ export declare function releaseConflictsHold(apiClient: Pick<ApiClient, 'conflicts'>, branch: string): Promise<ReleaseResult>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * An admin's release of a branch held as its record of conflicts was set aside
3
+ * (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
4
+ * again, as the release may have landed.
5
+ */
6
+ import { isGatewayFailure, isUnanswered } from './unanswered.js';
7
+ import { getErrorMessage } from '../utils/error.js';
8
+ const UNKNOWN = {
9
+ error: "The release may have gone through even though the server didn't answer. Reload the page to see whether the branch is still on hold.",
10
+ tone: 'warning',
11
+ };
12
+ const STILL_HELD = {
13
+ error: "The server didn't answer, and the branch is still on hold. Try again in a moment.",
14
+ tone: 'warning',
15
+ };
16
+ export async function releaseConflictsHold(apiClient, branch) {
17
+ const settle = async () => {
18
+ const now = await apiClient.conflicts.list({ branch }).catch(() => undefined);
19
+ if (!now?.ok || !now.data)
20
+ return UNKNOWN;
21
+ // Not held now, but this read can't say whose release that was.
22
+ return now.data.held ? STILL_HELD : { released: true };
23
+ };
24
+ let result;
25
+ try {
26
+ result = await apiClient.conflicts.releaseHold({ branch });
27
+ }
28
+ catch (err) {
29
+ return isUnanswered(err) ? settle() : { error: getErrorMessage(err), tone: 'error' };
30
+ }
31
+ if (result.ok)
32
+ return { released: true, wasHeld: result.data?.released !== false };
33
+ if (isGatewayFailure(result))
34
+ return settle();
35
+ return {
36
+ error: result.error ?? "Couldn't release the hold. Try again in a moment.",
37
+ tone: result.status === 409 ? 'warning' : 'error',
38
+ };
39
+ }
@@ -0,0 +1,4 @@
1
+ /** Record a time the server reported, as of when its answer arrived. */
2
+ export declare function noteServerTime(serverTime: number, receivedAt?: number): void;
3
+ /** Now, by the server's clock as last heard; the browser's until then. */
4
+ export declare function serverNow(): number;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The server's clock as the editor last heard it, so a wait timed against a server timestamp
3
+ * (base-update-wait.ts) doesn't depend on the browser's clock being right. One offset per page:
4
+ * the editor talks to one server.
5
+ */
6
+ let offsetMs = 0;
7
+ /** Record a time the server reported, as of when its answer arrived. */
8
+ export function noteServerTime(serverTime, receivedAt = Date.now()) {
9
+ if (Number.isFinite(serverTime))
10
+ offsetMs = serverTime - receivedAt;
11
+ }
12
+ /** Now, by the server's clock as last heard; the browser's until then. */
13
+ export function serverNow() {
14
+ return Date.now() + offsetMs;
15
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
3
+ * value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
4
+ * Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
5
+ * List and block items are found by item key, so an undo lands on the item the change named
6
+ * wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
7
+ * by (`resolveThreadPath` in item-keys.ts).
8
+ */
9
+ import type { EntryChangeSide } from '../api/branch-entry-changes.js';
10
+ import type { CanopyPathSegment } from './canopy-path.js';
11
+ import type { EntryChange } from './entry-diff.js';
12
+ type FormValue = Record<string, unknown>;
13
+ /** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
14
+ export declare const formValueOfSide: (side: EntryChangeSide) => FormValue;
15
+ /** A form value as a side shaped like `like`, for comparing it with the base. */
16
+ export declare const sideOfFormValue: (value: FormValue, like: EntryChangeSide) => EntryChangeSide;
17
+ /**
18
+ * `value` with `next` at a change's place, records on the way made as needed; undefined when the
19
+ * place is inside an item `value` no longer has.
20
+ */
21
+ export declare const withValueAt: (value: FormValue, change: Pick<EntryChange, "dataPath">, next: unknown, bodyField?: string) => FormValue | undefined;
22
+ /** What `value` holds at a change's place; undefined where it has nothing there. */
23
+ export declare const valueAtChange: (value: FormValue, change: Pick<EntryChange, "dataPath">, bodyField?: string) => unknown;
24
+ /**
25
+ * Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
26
+ * nothing: a moved item placed as `side` places it, anything else the same value at the change's
27
+ * path, an added or removed item's whole content included.
28
+ */
29
+ export declare function holdsAs(value: FormValue, side: FormValue, change: EntryChange, bodyField?: string): boolean;
30
+ /** Whether a change has a place in the form value that an undo can set back. */
31
+ export declare const canSetBack: (change: EntryChange) => boolean;
32
+ /**
33
+ * `current` with `change` set to how `source` has it. A whole item added or removed is put in or
34
+ * taken out; a moved item goes back beside its neighbours in `source`; anything else takes
35
+ * `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
36
+ * such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
37
+ * on an md/mdx entry.
38
+ */
39
+ export declare function setChangeBack(current: FormValue, source: FormValue, change: EntryChange, bodyField?: string): FormValue | undefined;
40
+ /**
41
+ * The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
42
+ * undefined when an item on it isn't in `value`.
43
+ */
44
+ export declare function canopyPathOf(value: FormValue, change: EntryChange, bodyField?: string): CanopyPathSegment[] | undefined;
45
+ /**
46
+ * `current` with every one of `changes` set to how `source` has it, leaving anything else, such
47
+ * as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
48
+ * each lands beside a neighbour already back in place and the list ends in `source`'s order. A
49
+ * change that can't be placed is skipped.
50
+ */
51
+ export declare function setAllBack(current: FormValue, source: FormValue, changes: readonly EntryChange[], bodyField?: string): FormValue;
52
+ export {};
@@ -0,0 +1,241 @@
1
+ /**
2
+ * "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
3
+ * value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
4
+ * Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
5
+ * List and block items are found by item key, so an undo lands on the item the change named
6
+ * wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
7
+ * by (`resolveThreadPath` in item-keys.ts).
8
+ */
9
+ import equal from 'fast-deep-equal';
10
+ import { ITEM_KEY } from '../item-keys.js';
11
+ import { isPlainRecord } from '../validation/field-traversal.js';
12
+ /** The form key of an md/mdx body, which the diff names by the schema's body field. */
13
+ const FORM_BODY_KEY = 'body';
14
+ /** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
15
+ export const formValueOfSide = (side) => side.body === undefined ? { ...side.data } : { ...side.data, [FORM_BODY_KEY]: side.body };
16
+ /** A form value as a side shaped like `like`, for comparing it with the base. */
17
+ export const sideOfFormValue = (value, like) => {
18
+ if (like.body === undefined)
19
+ return { ...like, data: value };
20
+ const { [FORM_BODY_KEY]: body, ...data } = value;
21
+ return { ...like, data, body: typeof body === 'string' ? body : '' };
22
+ };
23
+ const indexOfKey = (list, key) => list.findIndex((item) => isPlainRecord(item) && item[ITEM_KEY] === key);
24
+ /** A change's data path in form keys: the body field, when it heads the path, is `body`. */
25
+ const formPath = (change, bodyField) => {
26
+ const path = change.dataPath ?? [];
27
+ const head = path[0];
28
+ return bodyField && head && 'field' in head && head.field === bodyField
29
+ ? [{ field: FORM_BODY_KEY }, ...path.slice(1)]
30
+ : path;
31
+ };
32
+ const step = (value, segment) => {
33
+ if ('field' in segment)
34
+ return isPlainRecord(value) ? value[segment.field] : undefined;
35
+ if ('item' in segment) {
36
+ if (!Array.isArray(value))
37
+ return undefined;
38
+ const index = indexOfKey(value, segment.item);
39
+ return index < 0 ? undefined : value[index];
40
+ }
41
+ return isPlainRecord(value) ? (isPlainRecord(value.value) ? value.value : value) : undefined;
42
+ };
43
+ const valueAt = (value, path) => path.reduce(step, value);
44
+ /**
45
+ * `value` with `next` at a change's place, records on the way made as needed; undefined when the
46
+ * place is inside an item `value` no longer has.
47
+ */
48
+ export const withValueAt = (value, change, next, bodyField) => {
49
+ const placed = updateAt(value, formPath(change, bodyField), () => next);
50
+ return placed && isPlainRecord(placed.value) ? placed.value : undefined;
51
+ };
52
+ /** What `value` holds at a change's place; undefined where it has nothing there. */
53
+ export const valueAtChange = (value, change, bodyField) => valueAt(value, formPath(change, bodyField));
54
+ /** What a `replace` returns when the change has nothing to act on, so it can't be placed. */
55
+ const NOT_PLACED = Symbol('not-placed');
56
+ /**
57
+ * `value` with what is at `path` replaced by `replace`'s result, copied on the way down; a
58
+ * record missing on the way is made, so a field comes back inside an object the branch cleared.
59
+ * Undefined when the path leads through an item or block the value no longer has. A record
60
+ * member replaced by undefined is dropped.
61
+ */
62
+ const updateAt = (value, path, replace) => {
63
+ if (path.length === 0) {
64
+ const next = replace(value);
65
+ return next === NOT_PLACED ? undefined : { value: next };
66
+ }
67
+ const [segment, ...rest] = path;
68
+ if ('field' in segment) {
69
+ const record = isPlainRecord(value) ? value : value == null ? {} : undefined;
70
+ if (!record)
71
+ return undefined;
72
+ const next = updateAt(record[segment.field], rest, replace);
73
+ if (!next)
74
+ return undefined;
75
+ const out = { ...record };
76
+ if (next.value === undefined)
77
+ delete out[segment.field];
78
+ else
79
+ out[segment.field] = next.value;
80
+ return { value: out };
81
+ }
82
+ if ('item' in segment) {
83
+ if (!Array.isArray(value))
84
+ return undefined;
85
+ const index = indexOfKey(value, segment.item);
86
+ if (index < 0)
87
+ return undefined;
88
+ const next = updateAt(value[index], rest, replace);
89
+ if (!next)
90
+ return undefined;
91
+ const out = [...value];
92
+ out[index] = next.value;
93
+ return { value: out };
94
+ }
95
+ if (!isPlainRecord(value))
96
+ return undefined;
97
+ if (!isPlainRecord(value.value))
98
+ return updateAt(value, rest, replace);
99
+ const next = updateAt(value.value, rest, replace);
100
+ return next && { value: { ...value, value: next.value } };
101
+ };
102
+ /**
103
+ * `item` placed in `list` (which lacks it) where `order` has it: after the nearest item before it
104
+ * in `order` that `list` holds, else before the nearest one after it, else at its place in `order`.
105
+ */
106
+ const placeLike = (list, item, key, order) => {
107
+ const at = indexOfKey(order, key);
108
+ const indexIn = (other) => isPlainRecord(other) ? indexOfKey(list, other[ITEM_KEY]) : -1;
109
+ for (let i = at - 1; i >= 0; i--) {
110
+ const index = indexIn(order[i]);
111
+ if (index >= 0)
112
+ return [...list.slice(0, index + 1), item, ...list.slice(index + 1)];
113
+ }
114
+ for (let i = at + 1; at > 0 && i < order.length; i++) {
115
+ const index = indexIn(order[i]);
116
+ if (index >= 0)
117
+ return [...list.slice(0, index), item, ...list.slice(index)];
118
+ }
119
+ return at > 0 ? [...list.slice(0, at), item, ...list.slice(at)] : [item, ...list];
120
+ };
121
+ const isNothing = (value) => value === undefined || isEmptyContainer(value);
122
+ /**
123
+ * Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
124
+ * nothing: a moved item placed as `side` places it, anything else the same value at the change's
125
+ * path, an added or removed item's whole content included.
126
+ */
127
+ export function holdsAs(value, side, change, bodyField) {
128
+ if (change.item && change.kind === 'moved') {
129
+ const placed = setChangeBack(value, side, change, bodyField);
130
+ return !!placed && equal(placed, value);
131
+ }
132
+ const path = formPath(change, bodyField);
133
+ const [mine, theirs] = [valueAt(value, path), valueAt(side, path)];
134
+ return (isNothing(mine) && isNothing(theirs)) || equal(mine, theirs);
135
+ }
136
+ /** Whether a change has a place in the form value that an undo can set back. */
137
+ export const canSetBack = (change) => change.dataPath !== undefined;
138
+ /**
139
+ * `current` with `change` set to how `source` has it. A whole item added or removed is put in or
140
+ * taken out; a moved item goes back beside its neighbours in `source`; anything else takes
141
+ * `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
142
+ * such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
143
+ * on an md/mdx entry.
144
+ */
145
+ export function setChangeBack(current, source, change, bodyField) {
146
+ if (!canSetBack(change))
147
+ return undefined;
148
+ const path = formPath(change, bodyField);
149
+ const last = path[path.length - 1];
150
+ let next;
151
+ if (change.item && last && 'item' in last) {
152
+ const listPath = path.slice(0, -1);
153
+ const order = valueAt(source, listPath);
154
+ const sourceList = Array.isArray(order) ? order : [];
155
+ const wanted = valueAt(source, path);
156
+ next = updateAt(current, listPath, (old) => {
157
+ const list = Array.isArray(old) ? old : [];
158
+ const index = indexOfKey(list, last.item);
159
+ if (wanted === undefined)
160
+ return index < 0 ? old : list.filter((_, i) => i !== index);
161
+ if (change.kind === 'moved') {
162
+ if (index < 0)
163
+ return NOT_PLACED;
164
+ const without = list.filter((_, i) => i !== index);
165
+ return placeLike(without, list[index], last.item, sourceList);
166
+ }
167
+ return index >= 0 ? old : placeLike(list, structuredClone(wanted), last.item, sourceList);
168
+ });
169
+ }
170
+ else {
171
+ const wanted = valueAt(source, path);
172
+ next = updateAt(current, path, () => wanted === undefined ? undefined : structuredClone(wanted));
173
+ }
174
+ const placed = next && isPlainRecord(next.value) ? next.value : undefined;
175
+ return placed && withoutEmptyContainers(placed, source, path);
176
+ }
177
+ const isEmptyContainer = (value) => Array.isArray(value)
178
+ ? value.length === 0
179
+ : isPlainRecord(value) && Object.keys(value).length === 0;
180
+ /**
181
+ * `value` without the records and lists on `path` that setting a change back left empty where
182
+ * `source` has none, so a value set back matches `source` rather than holding `seo: {}`.
183
+ */
184
+ const withoutEmptyContainers = (value, source, path) => {
185
+ let out = value;
186
+ for (let end = path.length; end > 0; end--) {
187
+ const at = path.slice(0, end);
188
+ if (!('field' in at[at.length - 1]))
189
+ continue;
190
+ if (valueAt(source, at) !== undefined || !isEmptyContainer(valueAt(out, at)))
191
+ continue;
192
+ const next = updateAt(out, at, () => undefined);
193
+ if (next && isPlainRecord(next.value))
194
+ out = next.value;
195
+ }
196
+ return out;
197
+ };
198
+ /**
199
+ * The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
200
+ * undefined when an item on it isn't in `value`.
201
+ */
202
+ export function canopyPathOf(value, change, bodyField) {
203
+ const out = [];
204
+ let at = value;
205
+ for (const segment of formPath(change, bodyField)) {
206
+ if ('item' in segment) {
207
+ const index = Array.isArray(at) ? indexOfKey(at, segment.item) : -1;
208
+ if (index < 0)
209
+ return undefined;
210
+ out.push(index);
211
+ }
212
+ else if ('field' in segment) {
213
+ out.push(segment.field);
214
+ }
215
+ at = step(at, segment);
216
+ }
217
+ return out;
218
+ }
219
+ /** An item change's place in `source`'s list: -1 for an item `source` lacks; undefined otherwise. */
220
+ const sourcePosition = (source, change, bodyField) => {
221
+ if (!change.item)
222
+ return undefined;
223
+ const path = formPath(change, bodyField);
224
+ const last = path[path.length - 1];
225
+ if (!last || !('item' in last))
226
+ return undefined;
227
+ const list = valueAt(source, path.slice(0, -1));
228
+ return Array.isArray(list) ? indexOfKey(list, last.item) : -1;
229
+ };
230
+ /**
231
+ * `current` with every one of `changes` set to how `source` has it, leaving anything else, such
232
+ * as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
233
+ * each lands beside a neighbour already back in place and the list ends in `source`'s order. A
234
+ * change that can't be placed is skipped.
235
+ */
236
+ export function setAllBack(current, source, changes, bodyField) {
237
+ const ordered = changes
238
+ .map((change) => ({ change, position: sourcePosition(source, change, bodyField) }))
239
+ .sort((a, b) => (a.position ?? -2) - (b.position ?? -2));
240
+ return ordered.reduce((value, { change }) => setChangeBack(value, source, change, bodyField) ?? value, current);
241
+ }
@@ -560,6 +560,11 @@ export declare class GitManager {
560
560
  * path. Any other failure, such as an unknown `rev`, throws.
561
561
  */
562
562
  showFile(rev: string, filePath: string): Promise<string | undefined>;
563
+ /**
564
+ * The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
565
+ * clone has no such blob. Any other failure throws.
566
+ */
567
+ readBlob(sha: string, maxBytes: number): Promise<string | 'too-large' | undefined>;
563
568
  ensureAuthor(author: {
564
569
  name: string;
565
570
  email: string;
@@ -1763,6 +1763,36 @@ export class GitManager {
1763
1763
  throw err;
1764
1764
  }
1765
1765
  }
1766
+ /**
1767
+ * The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
1768
+ * clone has no such blob. Any other failure throws.
1769
+ */
1770
+ async readBlob(sha, maxBytes) {
1771
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha))
1772
+ return undefined;
1773
+ let sizeText;
1774
+ try {
1775
+ sizeText = (await this.git.raw(['cat-file', '-s', '--end-of-options', sha])).trim();
1776
+ }
1777
+ catch (err) {
1778
+ // LC_ALL=C (gitChildEnv), so git's own wording is stable to match.
1779
+ if (/could not get object info|Not a valid object name/.test(getErrorMessage(err))) {
1780
+ return undefined;
1781
+ }
1782
+ throw err;
1783
+ }
1784
+ // simple-git resolves "" when git exits non-zero without writing to stderr, so anything but
1785
+ // digits is a failed read rather than an empty blob.
1786
+ if (!/^\d+$/.test(sizeText))
1787
+ throw new Error(`git cat-file -s ${sha} printed no size`);
1788
+ const size = Number(sizeText);
1789
+ if (size > maxBytes)
1790
+ return 'too-large';
1791
+ const text = await this.git.raw(['cat-file', 'blob', '--end-of-options', sha]);
1792
+ if (size > 0 && text === '')
1793
+ throw new Error(`git cat-file blob ${sha} printed nothing`);
1794
+ return text;
1795
+ }
1766
1796
  async ensureAuthor(author) {
1767
1797
  const config = (await this.git.listConfig());
1768
1798
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -12,6 +12,14 @@ export declare const ITEM_KEY = "_key";
12
12
  export declare const isItemKey: (value: unknown) => value is string;
13
13
  /** A new random item key: 12 Base58 characters. */
14
14
  export declare function mintItemKey(): string;
15
+ /**
16
+ * True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
17
+ * Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
18
+ * minted is 12 digits long, so it never has this shape.
19
+ */
20
+ export declare const hasContentKeyShape: (key: string) => boolean;
21
+ /** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
22
+ export declare const hasMachineKeyShape: (key: string) => boolean;
15
23
  /**
16
24
  * How `withItemKeys` keys an item that lacks a usable key, or repeats one an earlier item of its
17
25
  * list holds:
@@ -31,6 +39,11 @@ type ListField = ObjectFieldConfig | BlockFieldConfig;
31
39
  export declare function withItemKeys(fields: readonly FieldConfig[], data: Record<string, unknown>, fill: ItemKeyFill): Record<string, unknown>;
32
40
  /** `item`, a copy about to join `field`'s list, with fresh keys for it and every item inside it. */
33
41
  export declare function withFreshItemKeys<T>(field: ListField, item: T): T;
42
+ /**
43
+ * The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
44
+ * key, else a hash of its content. Undefined for an item that isn't a record.
45
+ */
46
+ export declare const itemKeysAsRead: (items: readonly unknown[]) => Array<string | undefined>;
34
47
  /**
35
48
  * The keys of the list items on `canopyPath` in `value`, outermost first: what a new field thread
36
49
  * stores to name them. Undefined when an item on the path has no usable key or the path isn't
package/dist/item-keys.js CHANGED
@@ -40,6 +40,14 @@ function contentKey(item) {
40
40
  } while (n > 0);
41
41
  return `h${out}`;
42
42
  }
43
+ /**
44
+ * True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
45
+ * Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
46
+ * minted is 12 digits long, so it never has this shape.
47
+ */
48
+ export const hasContentKeyShape = (key) => /^h[1-9A-HJ-NP-Za-km-z]{1,10}(-\d+)?$/.test(key);
49
+ /** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
50
+ export const hasMachineKeyShape = (key) => hasContentKeyShape(key) || /^[1-9A-HJ-NP-Za-km-z]{12}$/.test(key);
43
51
  const isListField = (field) => field.type === 'block' || (field.type === 'object' && !!field.list);
44
52
  /**
45
53
  * `data` with every object-list item and block keyed, each unique in its list, nested lists
@@ -72,6 +80,22 @@ export function withFreshItemKeys(field, item) {
72
80
  return keyList(field, [item], 'fresh')[0];
73
81
  }
74
82
  function keyList(field, items, fill) {
83
+ const keys = listKeys(items, fill);
84
+ let changed = false;
85
+ const out = items.map((item, i) => {
86
+ if (!isPlainRecord(item))
87
+ return item;
88
+ let next = keyInside(field, item, fill);
89
+ if (keys[i] !== item[ITEM_KEY])
90
+ next = { ...next, [ITEM_KEY]: keys[i] };
91
+ if (next !== item)
92
+ changed = true;
93
+ return next;
94
+ });
95
+ return changed ? out : items;
96
+ }
97
+ /** The key each record of `items` ends with under `fill`; undefined for an item that isn't one. */
98
+ function listKeys(items, fill) {
75
99
  const held = new Set();
76
100
  if (fill !== 'fresh') {
77
101
  for (const item of items) {
@@ -80,27 +104,25 @@ function keyList(field, items, fill) {
80
104
  }
81
105
  }
82
106
  const kept = new Set();
83
- let changed = false;
84
- const out = items.map((item) => {
107
+ return items.map((item) => {
85
108
  if (!isPlainRecord(item))
86
- return item;
87
- let next = keyInside(field, item, fill);
109
+ return undefined;
88
110
  const own = item[ITEM_KEY];
89
111
  if (fill !== 'fresh' && isItemKey(own) && !kept.has(own)) {
90
112
  kept.add(own);
113
+ return own;
91
114
  }
92
- else {
93
- const key = fill === 'content' ? unheld(contentKey(item), held) : mintItemKey();
94
- held.add(key);
95
- kept.add(key);
96
- next = { ...next, [ITEM_KEY]: key };
97
- }
98
- if (next !== item)
99
- changed = true;
100
- return next;
115
+ const key = fill === 'content' ? unheld(contentKey(item), held) : mintItemKey();
116
+ held.add(key);
117
+ kept.add(key);
118
+ return key;
101
119
  });
102
- return changed ? out : items;
103
120
  }
121
+ /**
122
+ * The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
123
+ * key, else a hash of its content. Undefined for an item that isn't a record.
124
+ */
125
+ export const itemKeysAsRead = (items) => listKeys(items, 'content');
104
126
  /** `key`, or with a suffix when another item of the list already holds it (identical items). */
105
127
  function unheld(key, held) {
106
128
  let out = key;