canopycms 0.8.2 → 0.9.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 +79 -47
- package/dist/ai/json-to-markdown.js +2 -0
- package/dist/ai/resolve-branch.js +2 -1
- package/dist/api/branch-entry-changes.js +2 -4
- package/dist/authorization/branch.d.ts +10 -4
- package/dist/authorization/branch.js +24 -4
- package/dist/authorization/types.d.ts +1 -1
- package/dist/cli/cli.js +1984 -1630
- package/dist/cli/configured-asset-store.js +2 -8
- package/dist/cli/generate-ai-content.js +86 -16
- package/dist/cli/init.js +1 -1
- package/dist/cli/site-config.d.ts +9 -0
- package/dist/cli/site-config.js +17 -0
- package/dist/cli/sync.js +226 -74
- 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/FormRenderer.js +5 -0
- package/dist/editor/components/EntryChangesDrawer.js +3 -2
- 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/theme.js +12 -4
- package/dist/entry-schema-registry.js +2 -1
- package/dist/services.d.ts +3 -2
- package/dist/static/index.js +5 -2
- package/dist/system-users.d.ts +2 -0
- package/dist/system-users.js +6 -1
- 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/package.json +1 -1
|
@@ -8,6 +8,7 @@ import { diffLines, diffWordsWithSpace } from 'diff';
|
|
|
8
8
|
import equal from 'fast-deep-equal';
|
|
9
9
|
import { ITEM_KEY } from '../item-keys.js';
|
|
10
10
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
|
+
import { isCalendarDay, localMidnight } from '../utils/calendar-day.js';
|
|
11
12
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
12
13
|
import { isPlainRecord, resolveBlockItem } from '../validation/field-traversal.js';
|
|
13
14
|
import { listItemHeading, listItemTitle } from './fields/list-item-title.js';
|
|
@@ -89,7 +90,7 @@ const ISO_CHARS = /^[\d:.+\-TZ]+$/;
|
|
|
89
90
|
const formatDate = (iso, dateOnly, precision = 'minute') => {
|
|
90
91
|
if (dateOnly) {
|
|
91
92
|
const [year, month, day] = iso.split('-').map(Number);
|
|
92
|
-
return
|
|
93
|
+
return localMidnight(year, month, day).toLocaleDateString(undefined, {
|
|
93
94
|
year: 'numeric',
|
|
94
95
|
month: 'short',
|
|
95
96
|
day: 'numeric',
|
|
@@ -190,6 +191,11 @@ class Walker {
|
|
|
190
191
|
}
|
|
191
192
|
break;
|
|
192
193
|
}
|
|
194
|
+
case 'date':
|
|
195
|
+
if (isCalendarDay(value)) {
|
|
196
|
+
return { kind: 'date', iso: value, text: formatDate(value, true), dateOnly: true };
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
193
199
|
case 'datetime':
|
|
194
200
|
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
|
|
195
201
|
return DATE_ONLY.test(value)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface DateFieldProps {
|
|
3
|
+
id?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
dataCanopyField?: string;
|
|
9
|
+
/** Shows the value without accepting edits. */
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
/** A required field has no Remove: a save needs a day in it. */
|
|
12
|
+
required?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Editor for `type: 'date'` fields: a day, stored as `YYYY-MM-DD`, with no time and no zone. Only
|
|
16
|
+
* a pick or Remove writes, so a loaded value saves back unchanged, and a save refuses one that is
|
|
17
|
+
* no real day.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DateField: React.FC<DateFieldProps>;
|
|
@@ -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;
|
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/services.d.ts
CHANGED
|
@@ -36,8 +36,9 @@ export interface CanopyServices {
|
|
|
36
36
|
config: CanopyConfig;
|
|
37
37
|
/**
|
|
38
38
|
* Re-detect the active branch from git HEAD (dev mode only, cached 5s).
|
|
39
|
-
* Silently updates config if the branch changed —
|
|
40
|
-
*
|
|
39
|
+
* Silently updates config if the branch changed — non-editor content serving follows it, as
|
|
40
|
+
* does which branch gets dev's active-branch access grant (authorization/branch.ts); the editor
|
|
41
|
+
* is pinned to its own branch via URL params.
|
|
41
42
|
*/
|
|
42
43
|
refreshActiveBranch: () => Promise<void>;
|
|
43
44
|
/**
|
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/system-users.d.ts
CHANGED
|
@@ -8,5 +8,7 @@ export declare const SYSTEM_USER_ID = "canopycms-system";
|
|
|
8
8
|
export declare const CONTENT_READER_USER_ID = "canopycms-content-reader";
|
|
9
9
|
/** Owner recorded for the active branch a server context provisions (context.ts). */
|
|
10
10
|
export declare const CONTEXT_USER_ID = "canopycms-context";
|
|
11
|
+
/** Owner recorded for the active branch the AI content route provisions (ai/resolve-branch.ts). */
|
|
12
|
+
export declare const AI_USER_ID = "canopycms-ai";
|
|
11
13
|
/** True for a user id CanopyCMS acts as; the editor shows these as "CanopyCMS bot". */
|
|
12
14
|
export declare const isSystemUserId: (userId: string) => boolean;
|
package/dist/system-users.js
CHANGED
|
@@ -8,5 +8,10 @@ export const SYSTEM_USER_ID = 'canopycms-system';
|
|
|
8
8
|
export const CONTENT_READER_USER_ID = 'canopycms-content-reader';
|
|
9
9
|
/** Owner recorded for the active branch a server context provisions (context.ts). */
|
|
10
10
|
export const CONTEXT_USER_ID = 'canopycms-context';
|
|
11
|
+
/** Owner recorded for the active branch the AI content route provisions (ai/resolve-branch.ts). */
|
|
12
|
+
export const AI_USER_ID = 'canopycms-ai';
|
|
11
13
|
/** True for a user id CanopyCMS acts as; the editor shows these as "CanopyCMS bot". */
|
|
12
|
-
export const isSystemUserId = (userId) => userId === SYSTEM_USER_ID ||
|
|
14
|
+
export const isSystemUserId = (userId) => userId === SYSTEM_USER_ID ||
|
|
15
|
+
userId === CONTENT_READER_USER_ID ||
|
|
16
|
+
userId === CONTEXT_USER_ID ||
|
|
17
|
+
userId === AI_USER_ID;
|
|
@@ -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);
|
|
@@ -19,6 +19,7 @@ import { referenceValueId, resolveBlockItem, traverseFields } from './field-trav
|
|
|
19
19
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
20
20
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
21
21
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
22
|
+
import { isCalendarDay } from '../utils/calendar-day.js';
|
|
22
23
|
const KNOWN_FIELD_TYPES = new Set(fieldTypes);
|
|
23
24
|
/** Field types whose value is a plain string. */
|
|
24
25
|
const STRING_FIELD_TYPES = new Set(['string', 'markdown', 'mdx', 'code']);
|
|
@@ -104,6 +105,11 @@ function validateScalar(field, value, path) {
|
|
|
104
105
|
return [{ fieldPath: path, message: 'Expected true or false' }];
|
|
105
106
|
}
|
|
106
107
|
return [];
|
|
108
|
+
case 'date':
|
|
109
|
+
if (value !== '' && !isCalendarDay(value)) {
|
|
110
|
+
return [{ fieldPath: path, message: 'Expected a real date, written YYYY-MM-DD' }];
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
107
113
|
case 'datetime':
|
|
108
114
|
if (typeof value !== 'string' || (value !== '' && Number.isNaN(Date.parse(value)))) {
|
|
109
115
|
return [{ fieldPath: path, message: 'Expected a valid date/time string' }];
|
|
@@ -143,7 +149,7 @@ function isEmptyForRequired(field, value) {
|
|
|
143
149
|
if (Array.isArray(value))
|
|
144
150
|
return value.length === 0;
|
|
145
151
|
if (typeof value === 'string') {
|
|
146
|
-
// Strings, selects, references,
|
|
152
|
+
// Strings, selects, references, dates: empty string means "not filled in".
|
|
147
153
|
return value.trim() === '';
|
|
148
154
|
}
|
|
149
155
|
return false;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/** A record of named fields: not null, and not an array, which spreading would index-key. */
|
|
9
9
|
export function isPlainRecord(value) {
|
|
10
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
return (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date));
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Resolve a block item to its template fields and nested data record.
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.9.0";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.9.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|