canopycms 0.8.3 → 0.10.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.
- package/CHANGELOG.md +73 -47
- package/dist/ai/json-to-markdown.js +2 -0
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-entry-changes.js +2 -4
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +85 -16
- package/dist/cli/generate-ai-content.js +83 -15
- package/dist/cli/init.js +1 -1
- package/dist/config/types.d.ts +4 -4
- package/dist/config/types.js +1 -1
- package/dist/config/validation.d.ts +9 -3
- package/dist/config/validation.js +30 -5
- package/dist/content-listing.js +2 -3
- package/dist/content-store.js +5 -5
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +5 -1
- package/dist/editor/FormRenderer.js +5 -0
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/components/EntryChangesDrawer.js +3 -2
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/entry-diff.js +7 -1
- package/dist/editor/fields/DateField.d.ts +19 -0
- package/dist/editor/fields/DateField.js +53 -0
- package/dist/editor/fields/DateTimeField.d.ts +10 -0
- package/dist/editor/fields/DateTimeField.js +5 -15
- package/dist/editor/fields/list-item-title.js +2 -1
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/theme.js +12 -4
- package/dist/entry-schema-registry.js +2 -1
- package/dist/static/index.js +5 -2
- package/dist/types.d.ts +33 -0
- package/dist/utils/calendar-day.d.ts +23 -0
- package/dist/utils/calendar-day.js +102 -0
- package/dist/utils/content-serialize.d.ts +4 -2
- package/dist/utils/content-serialize.js +8 -6
- package/dist/validation/entry-validator.js +7 -1
- package/dist/validation/field-traversal.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useRef } from 'react';
|
|
3
|
+
import { ActionIcon, Tooltip } from '@mantine/core';
|
|
4
|
+
import { DatePickerInput } from '@mantine/dates';
|
|
5
|
+
import { IconX } from '@tabler/icons-react';
|
|
6
|
+
import dayjs from 'dayjs';
|
|
7
|
+
import { calendarDayToLocalDate, localDateToCalendarDay } from '../../utils/calendar-day.js';
|
|
8
|
+
import { CALENDAR_LABELS } from './DateTimeField.js';
|
|
9
|
+
import { LabelledInput } from './FieldLabel.js';
|
|
10
|
+
const VALUE_FORMAT = 'D MMM YYYY';
|
|
11
|
+
const REMOVE_LABEL = 'Remove date';
|
|
12
|
+
/**
|
|
13
|
+
* A level change unmounts the control that made it, and Esc only closes the calendar from inside
|
|
14
|
+
* it, so focus moves to the new level's first control.
|
|
15
|
+
*/
|
|
16
|
+
const keepFocusInCalendar = () => {
|
|
17
|
+
requestAnimationFrame(() => {
|
|
18
|
+
if (document.activeElement !== document.body)
|
|
19
|
+
return;
|
|
20
|
+
document.querySelector('[data-dates-dropdown] button')?.focus();
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
/** What a stored value that is no real day says: it is the only clue to the day meant. */
|
|
24
|
+
const notADay = (value, readOnly, required) => {
|
|
25
|
+
const next = required ? 'Pick a date.' : 'Pick a date or remove it.';
|
|
26
|
+
return `Stored as “${value}”, which isn't a real date.${readOnly ? '' : ` ${next}`}`;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Editor for `type: 'date'` fields: a day, stored as `YYYY-MM-DD`, with no time and no zone. Only
|
|
30
|
+
* a pick or Remove writes, so a loaded value saves back unchanged, and a save refuses one that is
|
|
31
|
+
* no real day.
|
|
32
|
+
*/
|
|
33
|
+
export const DateField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, required = false, }) => {
|
|
34
|
+
const generatedId = useId();
|
|
35
|
+
const inputId = id ?? generatedId;
|
|
36
|
+
const triggerRef = useRef(null);
|
|
37
|
+
const date = calendarDayToLocalDate(value);
|
|
38
|
+
const invalid = value !== '' && date === null;
|
|
39
|
+
const shown = date ? dayjs(date).format(VALUE_FORMAT) : invalid ? 'invalid value' : 'not set';
|
|
40
|
+
return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(DatePickerInput, { ref: triggerRef, id: inputId, "aria-label": `${label ?? 'Date'}, ${shown}`, description: description, error: invalid ? notADay(value, readOnly, required) : undefined, size: "sm", value: date, onChange: (next) => {
|
|
41
|
+
if (readOnly || !next)
|
|
42
|
+
return;
|
|
43
|
+
onChange(localDateToCalendarDay(next));
|
|
44
|
+
// The day is about to unmount under focus, which would drop a keyboard user to the page.
|
|
45
|
+
triggerRef.current?.focus();
|
|
46
|
+
}, valueFormat: VALUE_FORMAT, placeholder: readOnly ? undefined : 'Pick a date', ariaLabels: CALENDAR_LABELS, onLevelChange: keepFocusInCalendar,
|
|
47
|
+
// Red is for errors here, so weekends look like other days.
|
|
48
|
+
weekendDays: [], rightSection: value && !readOnly && !required ? (_jsx(Tooltip, { label: REMOVE_LABEL, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": REMOVE_LABEL, onClick: () => {
|
|
49
|
+
onChange('');
|
|
50
|
+
// The control goes with the value, so focus returns to the field.
|
|
51
|
+
triggerRef.current?.focus();
|
|
52
|
+
}, children: _jsx(IconX, { size: 14, "aria-hidden": true }) }) })) : undefined, rightSectionPointerEvents: "all", readOnly: readOnly, "data-canopy-field": dataCanopyField }) }));
|
|
53
|
+
};
|
|
@@ -11,4 +11,14 @@ export interface DateTimeFieldProps {
|
|
|
11
11
|
/** The field's `stampOnPublish`: the description says when a submit fills it. */
|
|
12
12
|
stampOnPublish?: boolean;
|
|
13
13
|
}
|
|
14
|
+
export declare const CALENDAR_LABELS: {
|
|
15
|
+
previousMonth: string;
|
|
16
|
+
nextMonth: string;
|
|
17
|
+
previousYear: string;
|
|
18
|
+
nextYear: string;
|
|
19
|
+
previousDecade: string;
|
|
20
|
+
nextDecade: string;
|
|
21
|
+
monthLevelControl: string;
|
|
22
|
+
yearLevelControl: string;
|
|
23
|
+
};
|
|
14
24
|
export declare const DateTimeField: React.FC<DateTimeFieldProps>;
|
|
@@ -4,6 +4,7 @@ import { ActionIcon, Tooltip } from '@mantine/core';
|
|
|
4
4
|
import { DateTimePicker } from '@mantine/dates';
|
|
5
5
|
import { IconX } from '@tabler/icons-react';
|
|
6
6
|
import dayjs from 'dayjs';
|
|
7
|
+
import { isRealDay, localMidnight } from '../../utils/calendar-day.js';
|
|
7
8
|
import { LabelledInput } from './FieldLabel.js';
|
|
8
9
|
/** Whether the locale uses a 12-hour clock, as the dropdown's native time input then does. */
|
|
9
10
|
const usesTwelveHourClock = () => {
|
|
@@ -15,7 +16,7 @@ const usesTwelveHourClock = () => {
|
|
|
15
16
|
const valueFormat = () => `D MMM YYYY, ${usesTwelveHourClock() ? 'h:mm:ss A' : 'HH:mm:ss'}`;
|
|
16
17
|
const REMOVE_LABEL = 'Remove date and time';
|
|
17
18
|
// The picker's own controls are icon-only or unlabelled without these.
|
|
18
|
-
const CALENDAR_LABELS = {
|
|
19
|
+
export const CALENDAR_LABELS = {
|
|
19
20
|
previousMonth: 'Previous month',
|
|
20
21
|
nextMonth: 'Next month',
|
|
21
22
|
previousYear: 'Previous year',
|
|
@@ -26,12 +27,6 @@ const CALENDAR_LABELS = {
|
|
|
26
27
|
yearLevelControl: 'Choose a year',
|
|
27
28
|
};
|
|
28
29
|
const DATE_PART = /^(\d{4})-(\d{2})-(\d{2})([Tt ]|$)/;
|
|
29
|
-
/** Whether the written year, month and day name a real day; `Date` rolls 30 Feb on to 1 Mar. */
|
|
30
|
-
const isCalendarDate = (year, month, day) => {
|
|
31
|
-
const lastOfMonth = new Date(0);
|
|
32
|
-
lastOfMonth.setUTCFullYear(year, month, 0);
|
|
33
|
-
return month >= 1 && month <= 12 && day >= 1 && day <= lastOfMonth.getUTCDate();
|
|
34
|
-
};
|
|
35
30
|
/**
|
|
36
31
|
* The stored value as a Date, or null for '', an unparsable value or a day that does not exist. A
|
|
37
32
|
* date-only value is local midnight; `Date` reads UTC midnight, the evening before west of UTC.
|
|
@@ -42,15 +37,10 @@ const toDate = (iso) => {
|
|
|
42
37
|
const datePart = DATE_PART.exec(iso);
|
|
43
38
|
if (datePart) {
|
|
44
39
|
const [year, month, day] = datePart.slice(1, 4).map(Number);
|
|
45
|
-
if (!
|
|
40
|
+
if (!isRealDay(year, month, day))
|
|
46
41
|
return null;
|
|
47
|
-
if (datePart[4] === '')
|
|
48
|
-
|
|
49
|
-
const local = new Date(0);
|
|
50
|
-
local.setFullYear(year, month - 1, day);
|
|
51
|
-
local.setHours(0, 0, 0, 0);
|
|
52
|
-
return local;
|
|
53
|
-
}
|
|
42
|
+
if (datePart[4] === '')
|
|
43
|
+
return localMidnight(year, month, day);
|
|
54
44
|
}
|
|
55
45
|
const date = new Date(iso);
|
|
56
46
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
@@ -22,7 +22,8 @@ const optionLabel = (field, value) => {
|
|
|
22
22
|
const textFields = (fields) => flattenFields(fields).filter((f) => (f.type === 'string' || f.type === 'number' || f.type === 'select') && !f.list);
|
|
23
23
|
/**
|
|
24
24
|
* Which field titles an object-list item, and its text: its `itemTitleField` value when that is
|
|
25
|
-
* a non-blank string or a number, else its first non-blank single-line
|
|
25
|
+
* a non-blank string, which a `date` holds too, or a number, else its first non-blank single-line
|
|
26
|
+
* string field.
|
|
26
27
|
*/
|
|
27
28
|
const titleSource = (record, objectField) => {
|
|
28
29
|
const named = objectField.itemTitleField;
|
|
@@ -28,8 +28,11 @@ export declare const CREATED_BRANCH_GRACE_MS = 120000;
|
|
|
28
28
|
interface BranchSummary {
|
|
29
29
|
name: string;
|
|
30
30
|
shortName?: string;
|
|
31
|
+
title?: string;
|
|
31
32
|
status: string;
|
|
32
33
|
createdBy?: string;
|
|
34
|
+
/** The creator's display name, when the listing carried it. */
|
|
35
|
+
ownerName?: string;
|
|
33
36
|
updatedAt?: string;
|
|
34
37
|
access: {
|
|
35
38
|
users: string[] | undefined;
|
|
@@ -126,6 +129,8 @@ export interface UseBranchManagerReturn {
|
|
|
126
129
|
/** Confirms first, taking an optional note; focus returns as for `handleApprove`. */
|
|
127
130
|
handleRequestChanges: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
|
|
128
131
|
handleDelete: (branchName: string) => Promise<void>;
|
|
132
|
+
/** Admin only: confirms first, then archives a branch whose pull request closed unmerged. */
|
|
133
|
+
handleArchive: (branchName: string) => Promise<void>;
|
|
129
134
|
handleReloadBranchData: () => Promise<void>;
|
|
130
135
|
loadBranches: () => Promise<void>;
|
|
131
136
|
}
|
|
@@ -201,7 +201,7 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
201
201
|
: undefined,
|
|
202
202
|
'Discard any unsaved or unmerged changes',
|
|
203
203
|
mayHoldUnpublished
|
|
204
|
-
? 'Remove the only copy of any changes
|
|
204
|
+
? 'Remove the only copy of any changes missing from GitHub, and the warning about them'
|
|
205
205
|
: undefined,
|
|
206
206
|
].filter((b) => b !== undefined);
|
|
207
207
|
openConfirm({
|
|
@@ -217,6 +217,16 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
217
217
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
218
218
|
});
|
|
219
219
|
};
|
|
220
|
+
/** Archiving is a one-way status change on a branch whose review is over. */
|
|
221
|
+
const showArchiveConfirmation = (branchName, inReview, onConfirm, onDismiss) => {
|
|
222
|
+
openConfirm({
|
|
223
|
+
title: EDITOR_ACTIONS.archiveBranch,
|
|
224
|
+
children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Archive “${branchName}”?\n\nIts pull request was closed without merging. This will:\n• Make the branch read-only and move it to “Archived”\n• Keep it marked as not merged\n\nAn archived branch can't go back to editing. This one stays until someone deletes it: automatic and bulk deletes only take merged branches.${inReview ? ` To keep working on it, use “${EDITOR_ACTIONS.withdrawFromReview}” instead.` : ''}` })),
|
|
225
|
+
labels: { confirm: EDITOR_ACTIONS.archiveBranch, cancel: 'Cancel' },
|
|
226
|
+
confirmProps: { color: 'orange', 'data-testid': 'confirm-archive-branch' },
|
|
227
|
+
...confirmModalHandlers(onConfirm, onDismiss),
|
|
228
|
+
});
|
|
229
|
+
};
|
|
220
230
|
/**
|
|
221
231
|
* How long a branch this session created stays overlaid on listings. Each warm
|
|
222
232
|
* server container caches the shared filesystem separately, so for its
|
|
@@ -381,8 +391,10 @@ export function useBranchManager(options) {
|
|
|
381
391
|
return {
|
|
382
392
|
name: b.name,
|
|
383
393
|
shortName: b.shortName,
|
|
394
|
+
title: b.title,
|
|
384
395
|
status: b.status,
|
|
385
396
|
createdBy: b.createdBy,
|
|
397
|
+
ownerName: branchesData?.ownerNames?.[b.createdBy],
|
|
386
398
|
updatedAt: b.updatedAt,
|
|
387
399
|
access: {
|
|
388
400
|
users: b.access.allowedUsers,
|
|
@@ -423,7 +435,7 @@ export function useBranchManager(options) {
|
|
|
423
435
|
submitBlocked: b.submitBlocked ?? true,
|
|
424
436
|
};
|
|
425
437
|
});
|
|
426
|
-
}, [branches, currentBranchName, options.comments]);
|
|
438
|
+
}, [branches, branchesData?.ownerNames, currentBranchName, options.comments]);
|
|
427
439
|
// Explicit reload: always issues a fresh, independent fetch (raw call, not
|
|
428
440
|
// SWR's `mutate()` revalidate path) so callers requesting a reload right
|
|
429
441
|
// after mount aren't coalesced against the still-in-flight automatic load
|
|
@@ -730,6 +742,45 @@ export function useBranchManager(options) {
|
|
|
730
742
|
}, () => resolve(), returnFocusTo);
|
|
731
743
|
}));
|
|
732
744
|
};
|
|
745
|
+
const handleArchive = (requested) => {
|
|
746
|
+
const branchNameToArchive = listedName(requested);
|
|
747
|
+
return singleFlight('archive', branchNameToArchive, () => new Promise((resolve, reject) => {
|
|
748
|
+
const status = latest.current.branches.find((b) => b.name === branchNameToArchive)?.status;
|
|
749
|
+
showArchiveConfirmation(shownName(branchNameToArchive), status === 'submitted' || status === 'approved', async () => {
|
|
750
|
+
options.setBusy(true);
|
|
751
|
+
try {
|
|
752
|
+
const result = await apiClient.workflow.archive({
|
|
753
|
+
branch: sendName(branchNameToArchive),
|
|
754
|
+
});
|
|
755
|
+
if (!result.ok)
|
|
756
|
+
throw actionError(result, 'Failed to archive branch');
|
|
757
|
+
notifications.show({ message: 'Branch archived', color: 'blue' });
|
|
758
|
+
forgetCreatedBranch(branchNameToArchive);
|
|
759
|
+
await loadBranches();
|
|
760
|
+
resolve();
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
if (err instanceof RefusedError && err.status === 409) {
|
|
764
|
+
// Nothing is lost, and the list says what changed.
|
|
765
|
+
notifications.show({
|
|
766
|
+
message: err.message,
|
|
767
|
+
color: 'yellow',
|
|
768
|
+
autoClose: false,
|
|
769
|
+
withCloseButton: true,
|
|
770
|
+
});
|
|
771
|
+
await reloadBranches();
|
|
772
|
+
resolve();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
await reportActionFailure(err, 'Failed to archive branch', EDITOR_ACTIONS.archiveBranch);
|
|
776
|
+
reject(err);
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
options.setBusy(false);
|
|
780
|
+
}
|
|
781
|
+
}, () => resolve());
|
|
782
|
+
}));
|
|
783
|
+
};
|
|
733
784
|
const handleApprove = (requested, returnFocusTo) => {
|
|
734
785
|
const branchNameToApprove = listedName(requested);
|
|
735
786
|
return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
|
|
@@ -838,8 +889,7 @@ export function useBranchManager(options) {
|
|
|
838
889
|
const branchNameToDelete = listedName(requested);
|
|
839
890
|
const listedBefore = latest.current.branches;
|
|
840
891
|
return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
|
|
841
|
-
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind
|
|
842
|
-
'unpublished-at-merge', async () => {
|
|
892
|
+
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), ['unpublished-at-merge', 'rewritten-at-merge'].includes(listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ?? ''), async () => {
|
|
843
893
|
options.setBusy(true);
|
|
844
894
|
try {
|
|
845
895
|
const result = await apiClient.branches.delete({
|
|
@@ -960,6 +1010,7 @@ export function useBranchManager(options) {
|
|
|
960
1010
|
handleApprove,
|
|
961
1011
|
handleRequestChanges,
|
|
962
1012
|
handleDelete,
|
|
1013
|
+
handleArchive,
|
|
963
1014
|
handleReloadBranchData,
|
|
964
1015
|
loadBranches,
|
|
965
1016
|
};
|
|
@@ -19,6 +19,8 @@ export declare const BRANCHES_KEY = "canopy:branches";
|
|
|
19
19
|
export interface BranchesData {
|
|
20
20
|
branches: BranchListItem[];
|
|
21
21
|
defaultBranch?: string;
|
|
22
|
+
/** Display names of the listed branches' creators, by user id. */
|
|
23
|
+
ownerNames?: Readonly<Record<string, string>>;
|
|
22
24
|
/**
|
|
23
25
|
* Client clock when this listing arrived. It makes listings fetched at
|
|
24
26
|
* different moments distinct `data` values even when their branches match,
|
|
@@ -33,10 +33,12 @@ export async function fetchBranches(apiClient, swr) {
|
|
|
33
33
|
if (!result.ok) {
|
|
34
34
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
const users = result.data?.users ?? [];
|
|
37
|
+
await seedUserProfiles(swr, users);
|
|
37
38
|
return {
|
|
38
39
|
branches: result.data?.branches ?? [],
|
|
39
40
|
defaultBranch: result.data?.defaultBranch,
|
|
41
|
+
ownerNames: Object.fromEntries(users.map((user) => [user.id, user.name])),
|
|
40
42
|
receivedAt: Date.now(),
|
|
41
43
|
};
|
|
42
44
|
}
|
package/dist/editor/theme.js
CHANGED
|
@@ -295,6 +295,7 @@ const canopyCssVariablesResolver = (theme) => ({
|
|
|
295
295
|
'--mantine-color-dimmed': 'var(--mantine-color-gray-7)',
|
|
296
296
|
'--mantine-color-placeholder': 'color-mix(in srgb, var(--mantine-color-gray-6) 60%, var(--mantine-color-gray-7))',
|
|
297
297
|
'--canopy-danger-text': 'var(--mantine-color-red-9)',
|
|
298
|
+
'--mantine-color-error': 'var(--canopy-danger-text)',
|
|
298
299
|
'--canopy-input-border': 'color-mix(in srgb, var(--mantine-color-gray-6) 80%, var(--mantine-color-gray-7))',
|
|
299
300
|
'--canopy-surface-subtle': 'var(--mantine-color-gray-0)',
|
|
300
301
|
'--canopy-surface-muted': 'var(--mantine-color-gray-1)',
|
|
@@ -307,6 +308,7 @@ const canopyCssVariablesResolver = (theme) => ({
|
|
|
307
308
|
'--mantine-color-dimmed': 'color-mix(in srgb, var(--mantine-color-dark-1) 85%, var(--mantine-color-dark-2))',
|
|
308
309
|
'--mantine-color-placeholder': 'color-mix(in srgb, var(--mantine-color-dark-2) 60%, var(--mantine-color-dark-1))',
|
|
309
310
|
'--canopy-danger-text': 'var(--mantine-color-red-3)',
|
|
311
|
+
'--mantine-color-error': 'var(--canopy-danger-text)',
|
|
310
312
|
'--canopy-input-border': 'color-mix(in srgb, var(--mantine-color-dark-2) 80%, var(--mantine-color-dark-1))',
|
|
311
313
|
'--canopy-surface-subtle': 'var(--mantine-color-dark-6)',
|
|
312
314
|
'--canopy-surface-muted': 'var(--mantine-color-dark-5)',
|
|
@@ -383,11 +385,15 @@ const CANOPY_STYLES = `
|
|
|
383
385
|
box-shadow: inset 0 0 0 1px currentColor;
|
|
384
386
|
}
|
|
385
387
|
.mantine-DateTimePicker-day[data-selected],
|
|
386
|
-
.mantine-DateTimePicker-pickerControl[data-selected]
|
|
388
|
+
.mantine-DateTimePicker-pickerControl[data-selected],
|
|
389
|
+
.mantine-DatePickerInput-day[data-selected],
|
|
390
|
+
.mantine-DatePickerInput-pickerControl[data-selected] {
|
|
387
391
|
background-color: var(--canopy-primary-fill);
|
|
388
392
|
}
|
|
389
393
|
.mantine-DateTimePicker-day[data-selected]:hover,
|
|
390
|
-
.mantine-DateTimePicker-pickerControl[data-selected]:hover
|
|
394
|
+
.mantine-DateTimePicker-pickerControl[data-selected]:hover,
|
|
395
|
+
.mantine-DatePickerInput-day[data-selected]:hover,
|
|
396
|
+
.mantine-DatePickerInput-pickerControl[data-selected]:hover {
|
|
391
397
|
background-color: var(--canopy-primary-fill-hover);
|
|
392
398
|
}
|
|
393
399
|
input.mantine-TimeInput-input::-webkit-datetime-edit-hour-field:focus,
|
|
@@ -398,10 +404,12 @@ input.mantine-TimeInput-input::-webkit-datetime-edit-ampm-field:focus {
|
|
|
398
404
|
}
|
|
399
405
|
.mantine-Menu-item[aria-disabled='true'],
|
|
400
406
|
.mantine-Combobox-empty,
|
|
401
|
-
.mantine-DateTimePicker-day[data-outside]
|
|
407
|
+
.mantine-DateTimePicker-day[data-outside],
|
|
408
|
+
.mantine-DatePickerInput-day[data-outside] {
|
|
402
409
|
font-style: var(--canopy-dimmed-font-style);
|
|
403
410
|
}
|
|
404
|
-
.mantine-DateTimePicker-day[data-outside]:not([data-disabled])
|
|
411
|
+
.mantine-DateTimePicker-day[data-outside]:not([data-disabled]),
|
|
412
|
+
.mantine-DatePickerInput-day[data-outside]:not([data-disabled]) {
|
|
405
413
|
opacity: 1;
|
|
406
414
|
}
|
|
407
415
|
`;
|
|
@@ -2,7 +2,7 @@ import { countTitleFields, findInvalidTitleFields, findTitleFieldsInLists, } fro
|
|
|
2
2
|
import { countBodyFields, findInvalidBodyFields, findReservedBodyFieldName, } from './utils/body-field.js';
|
|
3
3
|
import { RESOLVED_REFERENCE_KEYS, RESTRICTED_REFERENCE_MARKER } from './entry-schema.js';
|
|
4
4
|
import { flattenGroupFields } from './utils/flatten-group-fields.js';
|
|
5
|
-
import { ensureFieldNamesSpellInPaths, ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, ensureMarkdownFieldOptions, ensureMaxItemsPlacement, ensureStampOnPublishPlacement, } from './config/validation.js';
|
|
5
|
+
import { ensureFieldNamesSpellInPaths, ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, ensureMarkdownFieldOptions, ensureMaxItemsPlacement, ensureSingleDateFields, ensureStampOnPublishPlacement, } from './config/validation.js';
|
|
6
6
|
/** Look up a field's type by dotted path (e.g., "meta.order").
|
|
7
7
|
* Groups are transparent — their children are searched at the same path level. */
|
|
8
8
|
function findFieldType(fields, dottedPath) {
|
|
@@ -103,6 +103,7 @@ export function createEntrySchemaRegistry(registry) {
|
|
|
103
103
|
ensureMarkdownFieldOptions(schema);
|
|
104
104
|
ensureStampOnPublishPlacement(schema, `Entry schema registry entry "${key}"`);
|
|
105
105
|
ensureMaxItemsPlacement(schema, `Entry schema registry entry "${key}"`);
|
|
106
|
+
ensureSingleDateFields(schema, `Entry schema registry entry "${key}"`);
|
|
106
107
|
ensureNoFlattenedFieldNameCollisions(schema, `entry schema "${key}"`);
|
|
107
108
|
}
|
|
108
109
|
return registry;
|
package/dist/static/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { isBuildMode } from '../build-mode.js';
|
|
3
3
|
import { findUnknownKeys, validateEntryData, } from '../validation/entry-validator.js';
|
|
4
4
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
5
|
+
import { withCalendarDays } from '../utils/calendar-day.js';
|
|
5
6
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
6
7
|
import { parseSlug } from '../paths/index.js';
|
|
7
8
|
import { collectReferenceIds } from '../validation/field-traversal.js';
|
|
@@ -112,7 +113,9 @@ export async function collectStaticPaths(ctx, options = {}) {
|
|
|
112
113
|
* gray-matter parses unquoted YAML dates in hand-authored frontmatter (`date: YYYY-MM-DD`) into
|
|
113
114
|
* JS `Date` objects rather than strings. The shared validator's datetime check requires a string
|
|
114
115
|
* (see `validateScalar` in entry-validator.ts), so a legitimate hand-authored or migrated entry
|
|
115
|
-
* would otherwise fail this build guard.
|
|
116
|
+
* would otherwise fail this build guard. A bare date under a `date` field is read as the day
|
|
117
|
+
* written (`withCalendarDays`), and its ISO string is no day, so the scan does the same first.
|
|
118
|
+
* CMS-authored entries round-trip as quoted strings, so
|
|
116
119
|
* this only affects pre-existing content — which is the primary path for adopters retrofitting
|
|
117
120
|
* CanopyCMS onto an existing repo.
|
|
118
121
|
*
|
|
@@ -157,7 +160,7 @@ export function findInvalidEntries(items) {
|
|
|
157
160
|
for (const item of items) {
|
|
158
161
|
if (!item.schema)
|
|
159
162
|
continue;
|
|
160
|
-
const data = normalizeDatesDeep(item.data);
|
|
163
|
+
const data = normalizeDatesDeep(withCalendarDays(item.schema, item.data));
|
|
161
164
|
const errors = validateEntryData(item.schema, data);
|
|
162
165
|
if (errors.length > 0) {
|
|
163
166
|
invalid.push({ entryPath: item.entryPath, errors });
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,22 @@ export type CanopyGroupId = string;
|
|
|
12
12
|
* semantics (worker rebase skip list, a set/unset endpoint, UI).
|
|
13
13
|
*/
|
|
14
14
|
export type BranchStatus = 'editing' | 'submitted' | 'approved' | 'archived';
|
|
15
|
+
/** One cycle of the worker's retention sweep (worker/archived-sweep.ts). */
|
|
16
|
+
export interface ArchivedSweepReport {
|
|
17
|
+
/** Full names of the branches deleted. */
|
|
18
|
+
deleted: string[];
|
|
19
|
+
/** Due, but another process held the branch's provisioning lock; retried next cycle. */
|
|
20
|
+
skippedLocked: string[];
|
|
21
|
+
/** [REDACT] Served to the admin panel, so `error` is sanitized. */
|
|
22
|
+
failed: {
|
|
23
|
+
branch: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}[];
|
|
26
|
+
/** Due, beyond this cycle's cap; the next cycle takes the oldest of them. */
|
|
27
|
+
deferred: number;
|
|
28
|
+
/** Deleted, with something left behind, sanitized as `failed` is ([REDACT]). */
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
15
31
|
export type SyncStatus = 'synced' | 'pending-sync' | 'sync-failed';
|
|
16
32
|
export type ConflictStatus = 'clean' | 'conflicts-detected';
|
|
17
33
|
export type PullRequestState = 'open' | 'closed' | 'merged';
|
|
@@ -57,6 +73,14 @@ export type BranchHold = PullRequestHold | {
|
|
|
57
73
|
/** Admins see what to do in System health. */
|
|
58
74
|
| {
|
|
59
75
|
kind: 'unpublished-at-merge';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* An archived branch rewritten on GitHub, which may hold commits GitHub dropped. `commit`, the
|
|
79
|
+
* one GitHub no longer contains, is for System health; the API leaves it out.
|
|
80
|
+
*/
|
|
81
|
+
| {
|
|
82
|
+
kind: 'rewritten-at-merge';
|
|
83
|
+
commit?: string;
|
|
60
84
|
};
|
|
61
85
|
export interface BranchMetadata {
|
|
62
86
|
name: string;
|
|
@@ -106,6 +130,11 @@ export interface BranchMetadata {
|
|
|
106
130
|
* (worker auto-poll or markAsMerged). Absent for branches archived any other way.
|
|
107
131
|
*/
|
|
108
132
|
mergedAt?: string;
|
|
133
|
+
/**
|
|
134
|
+
* When the branch was archived, merged or not: the clock retention counts from. A branch
|
|
135
|
+
* archived before this field existed has only `mergedAt` (branch-archive.ts).
|
|
136
|
+
*/
|
|
137
|
+
archivedAt?: string;
|
|
109
138
|
/**
|
|
110
139
|
* Set by the worker when a rebase cycle fails for this branch; cleared on
|
|
111
140
|
* the next successful cycle (and on submit). firstAt survives repeated
|
|
@@ -292,6 +321,8 @@ export interface WorkerStatusReport {
|
|
|
292
321
|
updatedAt: string;
|
|
293
322
|
lastTaskCycleAt?: string;
|
|
294
323
|
lastGitSyncAt?: string;
|
|
324
|
+
/** The deployment's `archivedBranchRetentionDays`; absent when retention is off. */
|
|
325
|
+
archivedBranchRetentionDays?: number;
|
|
295
326
|
lastGitSyncError?: {
|
|
296
327
|
message: string;
|
|
297
328
|
at: string;
|
|
@@ -314,6 +345,8 @@ export interface WorkerStatusReport {
|
|
|
314
345
|
}[];
|
|
315
346
|
/** Optional for the same reason as `tracked` below. */
|
|
316
347
|
baseRefresh?: BaseRefreshReport;
|
|
348
|
+
/** The retention sweep's work this cycle; absent when it found nothing to do. */
|
|
349
|
+
archivedSweep?: ArchivedSweepReport;
|
|
317
350
|
/**
|
|
318
351
|
* Outcome of reconciling remote.git's `refs/heads/*` against GitHub's fetched
|
|
319
352
|
* tips, never moving a head the worker finds editor work in (worker/git-sync.ts's
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FieldConfig } from '../config/index.js';
|
|
2
|
+
/** Whether the year, month and day name a real day; `Date` rolls 30 Feb on into March. */
|
|
3
|
+
export declare const isRealDay: (year: number, month: number, day: number) => boolean;
|
|
4
|
+
/** Whether `value` is a `date` field's value: `YYYY-MM-DD`, naming a real day. */
|
|
5
|
+
export declare const isCalendarDay: (value: unknown) => value is string;
|
|
6
|
+
/**
|
|
7
|
+
* The first moment of that day in the browser's zone: 01:00 where a clock change skips midnight,
|
|
8
|
+
* the next day where a zone skipped the day whole. `new Date('YYYY-MM-DD')` is UTC midnight
|
|
9
|
+
* instead, the evening before west of UTC.
|
|
10
|
+
*/
|
|
11
|
+
export declare const localMidnight: (year: number, month: number, day: number) => Date;
|
|
12
|
+
/** A `date` value as a picker's local `Date`, or null when it is not a real day. */
|
|
13
|
+
export declare const calendarDayToLocalDate: (value: string) => Date | null;
|
|
14
|
+
/** The day a picker's local `Date` shows, as a `date` value. */
|
|
15
|
+
export declare const localDateToCalendarDay: (date: Date) => string;
|
|
16
|
+
/**
|
|
17
|
+
* `data` with each `date` field's bare YAML date as its `YYYY-MM-DD` string, nested fields
|
|
18
|
+
* included; the same object when none needed it, so a parser's shared object is never written to.
|
|
19
|
+
* Only the schema knows a field wants a day, so other fields' bare dates stay `Date`s.
|
|
20
|
+
*/
|
|
21
|
+
export declare function withCalendarDays(fields: readonly FieldConfig[], data: Record<string, unknown>): Record<string, unknown>;
|
|
22
|
+
/** `data` parsed from an entry file as every reader hands it on, so all of them agree. */
|
|
23
|
+
export declare const entryAsRead: (fields: readonly FieldConfig[], data: Record<string, unknown>) => Record<string, unknown>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { withItemKeys } from '../item-keys.js';
|
|
2
|
+
import { isPlainRecord, resolveBlockItem } from '../validation/field-traversal.js';
|
|
3
|
+
/**
|
|
4
|
+
* The value of a `date` field: `YYYY-MM-DD`, a day on the calendar with no time and no zone. It
|
|
5
|
+
* only meets a `Date` at its edges, a picker or a display and a YAML parser, and there it goes
|
|
6
|
+
* through the local or the UTC parts named below, never from one to the other.
|
|
7
|
+
*/
|
|
8
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
9
|
+
const MS_PER_DAY = 86_400_000;
|
|
10
|
+
const twoDigits = (n) => String(n).padStart(2, '0');
|
|
11
|
+
/** Whether the year, month and day name a real day; `Date` rolls 30 Feb on into March. */
|
|
12
|
+
export const isRealDay = (year, month, day) => {
|
|
13
|
+
const lastOfMonth = new Date(0);
|
|
14
|
+
lastOfMonth.setUTCFullYear(year, month, 0);
|
|
15
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= lastOfMonth.getUTCDate();
|
|
16
|
+
};
|
|
17
|
+
const dayParts = (value) => {
|
|
18
|
+
const match = typeof value === 'string' ? DAY.exec(value) : null;
|
|
19
|
+
if (!match)
|
|
20
|
+
return undefined;
|
|
21
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
22
|
+
return isRealDay(year, month, day) ? [year, month, day] : undefined;
|
|
23
|
+
};
|
|
24
|
+
/** Whether `value` is a `date` field's value: `YYYY-MM-DD`, naming a real day. */
|
|
25
|
+
export const isCalendarDay = (value) => dayParts(value) !== undefined;
|
|
26
|
+
/**
|
|
27
|
+
* The first moment of that day in the browser's zone: 01:00 where a clock change skips midnight,
|
|
28
|
+
* the next day where a zone skipped the day whole. `new Date('YYYY-MM-DD')` is UTC midnight
|
|
29
|
+
* instead, the evening before west of UTC.
|
|
30
|
+
*/
|
|
31
|
+
export const localMidnight = (year, month, day) => {
|
|
32
|
+
// setFullYear, since the constructor maps years 0-99 to 1900-1999.
|
|
33
|
+
const local = new Date(0);
|
|
34
|
+
local.setFullYear(year, month - 1, day);
|
|
35
|
+
local.setHours(0, 0, 0, 0);
|
|
36
|
+
return local;
|
|
37
|
+
};
|
|
38
|
+
/** A `date` value as a picker's local `Date`, or null when it is not a real day. */
|
|
39
|
+
export const calendarDayToLocalDate = (value) => {
|
|
40
|
+
const parts = dayParts(value);
|
|
41
|
+
return parts ? localMidnight(...parts) : null;
|
|
42
|
+
};
|
|
43
|
+
/** The day a picker's local `Date` shows, as a `date` value. */
|
|
44
|
+
export const localDateToCalendarDay = (date) => `${String(date.getFullYear()).padStart(4, '0')}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`;
|
|
45
|
+
/**
|
|
46
|
+
* The day gray-matter's js-yaml reads a bare `YYYY-MM-DD` as, or undefined for any other time of
|
|
47
|
+
* day. js-yaml reads it as UTC midnight, so the UTC parts are the day written whatever the
|
|
48
|
+
* machine's zone. A timestamp at UTC midnight reads alike, so it counts as a day.
|
|
49
|
+
*/
|
|
50
|
+
const dayOfBareDate = (value) => value.getTime() % MS_PER_DAY === 0 ? value.toISOString().slice(0, 10) : undefined;
|
|
51
|
+
const mapItems = (items, mapItem) => {
|
|
52
|
+
const next = items.map((item) => (isPlainRecord(item) ? mapItem(item) : item));
|
|
53
|
+
return next.every((item, i) => item === items[i]) ? items : next;
|
|
54
|
+
};
|
|
55
|
+
const blockItemWithDays = (field, item) => {
|
|
56
|
+
const resolved = resolveBlockItem(field, item);
|
|
57
|
+
if (!resolved)
|
|
58
|
+
return item;
|
|
59
|
+
const data = withCalendarDays(resolved.fields, resolved.data);
|
|
60
|
+
if (data === resolved.data)
|
|
61
|
+
return item;
|
|
62
|
+
return resolved.data === item ? data : { ...item, value: data };
|
|
63
|
+
};
|
|
64
|
+
const valueWithDays = (field, value) => {
|
|
65
|
+
if (field.type === 'date') {
|
|
66
|
+
return value instanceof Date ? (dayOfBareDate(value) ?? value) : value;
|
|
67
|
+
}
|
|
68
|
+
if (field.type === 'object') {
|
|
69
|
+
const { fields, list } = field;
|
|
70
|
+
if (list) {
|
|
71
|
+
return Array.isArray(value)
|
|
72
|
+
? mapItems(value, (item) => withCalendarDays(fields ?? [], item))
|
|
73
|
+
: value;
|
|
74
|
+
}
|
|
75
|
+
return isPlainRecord(value) ? withCalendarDays(fields ?? [], value) : value;
|
|
76
|
+
}
|
|
77
|
+
if (field.type === 'block' && Array.isArray(value)) {
|
|
78
|
+
return mapItems(value, (item) => blockItemWithDays(field, item));
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* `data` with each `date` field's bare YAML date as its `YYYY-MM-DD` string, nested fields
|
|
84
|
+
* included; the same object when none needed it, so a parser's shared object is never written to.
|
|
85
|
+
* Only the schema knows a field wants a day, so other fields' bare dates stay `Date`s.
|
|
86
|
+
*/
|
|
87
|
+
export function withCalendarDays(fields, data) {
|
|
88
|
+
let out = data;
|
|
89
|
+
for (const field of fields) {
|
|
90
|
+
if (field.type === 'group') {
|
|
91
|
+
out = withCalendarDays(field.fields, out);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const value = out[field.name];
|
|
95
|
+
const next = valueWithDays(field, value);
|
|
96
|
+
if (next !== value)
|
|
97
|
+
out = { ...out, [field.name]: next };
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** `data` parsed from an entry file as every reader hands it on, so all of them agree. */
|
|
102
|
+
export const entryAsRead = (fields, data) => withItemKeys(fields, withCalendarDays(fields, data), 'content');
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* is a schema question, answered one layer up by `findUnknownKeys`
|
|
17
17
|
* (validation/entry-validator.ts) at the API boundary, not by a schema-blind serialiser.
|
|
18
18
|
*/
|
|
19
|
+
import type { FieldConfig } from '../config/index.js';
|
|
19
20
|
import { type MarkdownBodyFormat } from './markdown-body-splice.js';
|
|
20
21
|
/**
|
|
21
22
|
* Serialise entry data as a YAML file, carrying the comments of `existingRaw` through.
|
|
@@ -34,6 +35,7 @@ export declare function serializeYaml(data: Record<string, unknown>, existingRaw
|
|
|
34
35
|
* would have produced. The result is kept only if the read path reads `data` back from it;
|
|
35
36
|
* otherwise gray-matter writes it, without the comments. A fresh string the read path would
|
|
36
37
|
* misread is quoted (`readsAsAnotherValue`), so this is left for a changed value meeting text the
|
|
37
|
-
* two parsers read differently, as 14 meets a file's `014`.
|
|
38
|
+
* two parsers read differently, as 14 meets a file's `014`. `fields`, the entry's schema, shapes
|
|
39
|
+
* what the read path reads, so a `date` field's bare date counts as the day it writes.
|
|
38
40
|
*/
|
|
39
|
-
export declare function serializeFrontmatter(editorBody: string, data: Record<string, unknown>, existingRaw: string | undefined, format: MarkdownBodyFormat): string;
|
|
41
|
+
export declare function serializeFrontmatter(editorBody: string, data: Record<string, unknown>, existingRaw: string | undefined, format: MarkdownBodyFormat, fields?: readonly FieldConfig[]): string;
|
|
@@ -21,6 +21,7 @@ import matter from 'gray-matter';
|
|
|
21
21
|
import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, Scalar, stringify as yamlStringify, visit, } from 'yaml';
|
|
22
22
|
import { isBlockStructuralKey } from '../validation/block-structural-keys.js';
|
|
23
23
|
import { ITEM_KEY, isItemKey } from '../item-keys.js';
|
|
24
|
+
import { withCalendarDays } from './calendar-day.js';
|
|
24
25
|
import { createDebugLogger } from './debug.js';
|
|
25
26
|
import { getErrorMessage } from './error.js';
|
|
26
27
|
import { preserveMarkdownSource } from './markdown-body-splice.js';
|
|
@@ -552,9 +553,9 @@ function asJson(value) {
|
|
|
552
553
|
return key === undefined || key === 'undefined' ? undefined : JSON.parse(key);
|
|
553
554
|
}
|
|
554
555
|
/** Frontmatter data as the read path hands it over the API, or undefined when it cannot split. */
|
|
555
|
-
function frontmatterAsRead(raw) {
|
|
556
|
+
function frontmatterAsRead(raw, fields) {
|
|
556
557
|
try {
|
|
557
|
-
return asJson(matter(raw, {}).data);
|
|
558
|
+
return asJson(withCalendarDays(fields, matter(raw, {}).data));
|
|
558
559
|
}
|
|
559
560
|
catch {
|
|
560
561
|
return undefined;
|
|
@@ -648,9 +649,10 @@ function readsAsAnotherValue(text, asKey) {
|
|
|
648
649
|
* would have produced. The result is kept only if the read path reads `data` back from it;
|
|
649
650
|
* otherwise gray-matter writes it, without the comments. A fresh string the read path would
|
|
650
651
|
* misread is quoted (`readsAsAnotherValue`), so this is left for a changed value meeting text the
|
|
651
|
-
* two parsers read differently, as 14 meets a file's `014`.
|
|
652
|
+
* two parsers read differently, as 14 meets a file's `014`. `fields`, the entry's schema, shapes
|
|
653
|
+
* what the read path reads, so a `date` field's bare date counts as the day it writes.
|
|
652
654
|
*/
|
|
653
|
-
export function serializeFrontmatter(editorBody, data, existingRaw, format) {
|
|
655
|
+
export function serializeFrontmatter(editorBody, data, existingRaw, format, fields = []) {
|
|
654
656
|
// An object, never the body string: `matter.stringify` re-parses a string as a whole file, so a
|
|
655
657
|
// body starting with `---` would be read as frontmatter and dropped.
|
|
656
658
|
const file = { content: bodyToWrite(editorBody, data, format, existingRaw) };
|
|
@@ -661,7 +663,7 @@ export function serializeFrontmatter(editorBody, data, existingRaw, format) {
|
|
|
661
663
|
return matter.stringify(file, data);
|
|
662
664
|
const onDisk = parseDocument(existingFrontmatter);
|
|
663
665
|
const toReconcile = onDisk.errors.length === 0
|
|
664
|
-
? withUnchangedAsOnDisk(data, frontmatterAsRead(existingRaw), onDisk.toJS())
|
|
666
|
+
? withUnchangedAsOnDisk(data, frontmatterAsRead(existingRaw, fields), onDisk.toJS())
|
|
665
667
|
: data;
|
|
666
668
|
const reconciled = reconcileYamlSource(existingFrontmatter, isPlainRecord(toReconcile) ? toReconcile : data, { rootAtColumnZero: true, quote: readsAsAnotherValue });
|
|
667
669
|
if (reconciled === undefined)
|
|
@@ -674,7 +676,7 @@ export function serializeFrontmatter(editorBody, data, existingRaw, format) {
|
|
|
674
676
|
},
|
|
675
677
|
},
|
|
676
678
|
});
|
|
677
|
-
if (isDeepStrictEqual(frontmatterAsRead(written), asJson(data)))
|
|
679
|
+
if (isDeepStrictEqual(frontmatterAsRead(written, fields), asJson(data)))
|
|
678
680
|
return written;
|
|
679
681
|
log.debug('content-serialize', 'reconciled frontmatter reads back differently; writing as gray-matter prints it');
|
|
680
682
|
return matter.stringify(file, data);
|