canopycms 0.4.0 → 0.6.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 +78 -42
- package/dist/ai/generate.d.ts +3 -0
- package/dist/ai/generate.js +12 -29
- package/dist/ai/handler.js +1 -0
- package/dist/ai/types.d.ts +3 -9
- package/dist/api/reference-options.d.ts +2 -5
- package/dist/api/reference-options.js +21 -2
- package/dist/api/schema.d.ts +4 -4
- package/dist/build/generate-ai-content.js +1 -0
- package/dist/cli/cli.js +136 -39
- package/dist/cli/generate-ai-content.js +75 -32
- package/dist/cli/init.js +34 -5
- package/dist/cli/template-files/cms-stack.ts.template +3 -0
- package/dist/cli/templates.js +5 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +74 -0
- package/dist/config/schemas/config.js +21 -0
- package/dist/config/schemas/field.d.ts +30 -0
- package/dist/config/schemas/field.js +22 -0
- package/dist/config/types.d.ts +39 -0
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +33 -0
- package/dist/editor/FormRenderer.js +15 -13
- package/dist/editor/copy.d.ts +5 -0
- package/dist/editor/copy.js +5 -0
- package/dist/editor/fields/BlockField.d.ts +2 -0
- package/dist/editor/fields/BlockField.js +15 -8
- package/dist/editor/fields/ItemList.d.ts +21 -3
- package/dist/editor/fields/ItemList.js +16 -8
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +6 -2
- package/dist/editor/fields/ObjectListField.js +9 -3
- package/dist/editor/fields/ReferenceField.d.ts +6 -7
- package/dist/editor/fields/ReferenceField.js +28 -122
- package/dist/editor/fields/ReferenceListField.d.ts +41 -0
- package/dist/editor/fields/ReferenceListField.js +139 -0
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +6 -3
- package/dist/editor/fields/StringListField.d.ts +10 -1
- package/dist/editor/fields/StringListField.js +18 -8
- package/dist/editor/fields/item-expansion.d.ts +7 -3
- package/dist/editor/fields/item-expansion.js +6 -4
- package/dist/editor/fields/list-item-keys.d.ts +6 -5
- package/dist/editor/fields/list-item-keys.js +45 -13
- package/dist/editor/fields/reference-noindex.d.ts +37 -0
- package/dist/editor/fields/reference-noindex.js +48 -0
- package/dist/editor/fields/use-reference-options.d.ts +36 -0
- package/dist/editor/fields/use-reference-options.js +121 -0
- package/dist/editor/form-outline.d.ts +2 -2
- package/dist/editor/form-outline.js +4 -2
- package/dist/editor/theme.js +5 -0
- package/dist/entry-schema-registry.d.ts +1 -1
- package/dist/entry-schema-registry.js +3 -2
- package/dist/entry-schema.d.ts +5 -3
- package/dist/entry-schema.js +2 -1
- package/dist/reference-resolver.d.ts +18 -1
- package/dist/reference-resolver.js +175 -8
- package/dist/schema/schema-store.d.ts +2 -2
- package/dist/static/index.d.ts +8 -4
- package/dist/static/index.js +38 -13
- package/dist/static/seo.d.ts +7 -5
- package/dist/static/seo.js +13 -6
- package/dist/validation/entry-validator.d.ts +5 -0
- package/dist/validation/entry-validator.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ComboboxItem } from '@mantine/core';
|
|
3
|
+
import type { ReferenceOption } from './use-reference-options.js';
|
|
4
|
+
/** The badge on a picked or offered entry whose own form hides it from search. */
|
|
5
|
+
export declare const HIDDEN_FROM_SEARCH = "Hidden from search";
|
|
6
|
+
/** `inOption` lets a highlighted option's fill recolour it (theme.tsx), so it stays legible. */
|
|
7
|
+
export declare const HiddenFromSearchBadge: React.FC<{
|
|
8
|
+
inOption?: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* A reference Select's `renderOption`: the label, with the badge on a hidden entry. Draws the
|
|
12
|
+
* check mark itself, since a custom option replaces Mantine's.
|
|
13
|
+
*/
|
|
14
|
+
export declare function referenceOptionRenderer(options: readonly ReferenceOption[]): ({ option, checked, }: {
|
|
15
|
+
option: ComboboxItem;
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
/** A pick whose target is hidden from search, as the note names it. */
|
|
19
|
+
export interface HiddenPick {
|
|
20
|
+
label: string;
|
|
21
|
+
fieldLabel: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One sentence per noindex field label, since entry types can label that field differently:
|
|
25
|
+
* "‘A’ has “Hide from search engines” turned on, so pages that list only visible entries won't
|
|
26
|
+
* show it."
|
|
27
|
+
* @internal Exported for tests.
|
|
28
|
+
*/
|
|
29
|
+
export declare function hiddenPickSentences(picks: readonly HiddenPick[]): string[];
|
|
30
|
+
/**
|
|
31
|
+
* The yellow note about hidden picks: above a list, under its heading, or under a single
|
|
32
|
+
* field's input. It warns rather than filters: an editor may be lining up an entry that is
|
|
33
|
+
* about to go public.
|
|
34
|
+
*/
|
|
35
|
+
export declare const HiddenPicksNote: React.FC<{
|
|
36
|
+
picks: readonly HiddenPick[];
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Alert, Badge, Group } from '@mantine/core';
|
|
4
|
+
import { IconAlertTriangle, IconCheck } from '@tabler/icons-react';
|
|
5
|
+
/** The badge on a picked or offered entry whose own form hides it from search. */
|
|
6
|
+
export const HIDDEN_FROM_SEARCH = 'Hidden from search';
|
|
7
|
+
/** `inOption` lets a highlighted option's fill recolour it (theme.tsx), so it stays legible. */
|
|
8
|
+
export const HiddenFromSearchBadge = ({ inOption }) => (_jsx(Badge, { variant: "light", color: "yellow", size: "sm", tt: "none", style: { flexShrink: 0 }, ...(inOption && { 'data-canopy-option-badge': true }), children: HIDDEN_FROM_SEARCH }));
|
|
9
|
+
/**
|
|
10
|
+
* A reference Select's `renderOption`: the label, with the badge on a hidden entry. Draws the
|
|
11
|
+
* check mark itself, since a custom option replaces Mantine's.
|
|
12
|
+
*/
|
|
13
|
+
export function referenceOptionRenderer(options) {
|
|
14
|
+
const hiddenValues = new Set(options.flatMap((o) => (o.noindex ? [o.value] : [])));
|
|
15
|
+
return function ReferenceOptionLabel({ option, checked, }) {
|
|
16
|
+
const hidden = hiddenValues.has(option.value);
|
|
17
|
+
return (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1, minWidth: 0 }, children: option.label }), hidden && _jsx(HiddenFromSearchBadge, { inOption: true })] }));
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const quoted = (label) => `‘${label}’`;
|
|
21
|
+
const listNames = (names) => names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
22
|
+
/**
|
|
23
|
+
* One sentence per noindex field label, since entry types can label that field differently:
|
|
24
|
+
* "‘A’ has “Hide from search engines” turned on, so pages that list only visible entries won't
|
|
25
|
+
* show it."
|
|
26
|
+
* @internal Exported for tests.
|
|
27
|
+
*/
|
|
28
|
+
export function hiddenPickSentences(picks) {
|
|
29
|
+
const byField = new Map();
|
|
30
|
+
for (const pick of picks) {
|
|
31
|
+
byField.set(pick.fieldLabel, [...(byField.get(pick.fieldLabel) ?? []), quoted(pick.label)]);
|
|
32
|
+
}
|
|
33
|
+
return [...byField].map(([fieldLabel, names]) => {
|
|
34
|
+
const one = names.length === 1;
|
|
35
|
+
return (`${listNames(names)} ${one ? 'has' : 'have'} “${fieldLabel}” turned on, so pages ` +
|
|
36
|
+
`that list only visible entries won't show ${one ? 'it' : 'them'}.`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The yellow note about hidden picks: above a list, under its heading, or under a single
|
|
41
|
+
* field's input. It warns rather than filters: an editor may be lining up an entry that is
|
|
42
|
+
* about to go public.
|
|
43
|
+
*/
|
|
44
|
+
export const HiddenPicksNote = ({ picks }) => {
|
|
45
|
+
if (picks.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), p: "xs", "data-testid": "reference-hidden-note", children: hiddenPickSentences(picks).map((sentence) => (_jsx("div", { children: sentence }, sentence))) }));
|
|
48
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReferenceOptionSort } from '../../config/index.js';
|
|
2
|
+
/** An entry a reference field can point at. */
|
|
3
|
+
export interface ReferenceOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** The target is hidden from search; `fieldLabel` names the setting on that entry's form. */
|
|
7
|
+
noindex?: {
|
|
8
|
+
fieldLabel: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** The id a stored reference holds: the id itself, or a resolved read's `{ id, … }`. */
|
|
12
|
+
export declare const extractId: (val: unknown) => string;
|
|
13
|
+
/** Label for a set value the options omit (a restricted, missing or unlisted target). */
|
|
14
|
+
export declare function unlistedReferenceLabel(value: unknown, id: string): string;
|
|
15
|
+
export interface ReferenceOptionsQuery {
|
|
16
|
+
/** Used as given, with no fetch. */
|
|
17
|
+
staticOptions?: ReferenceOption[];
|
|
18
|
+
collections?: string[];
|
|
19
|
+
entryTypes?: string[];
|
|
20
|
+
displayField: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
/** The order the endpoint returns options in. Default: by label. */
|
|
23
|
+
optionSort?: ReferenceOptionSort;
|
|
24
|
+
}
|
|
25
|
+
export interface ReferenceOptions {
|
|
26
|
+
options: ReferenceOption[];
|
|
27
|
+
loading: boolean;
|
|
28
|
+
error: string | null;
|
|
29
|
+
retry: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The entries a reference field offers: `staticOptions` as given, else one request per field to
|
|
33
|
+
* the reference-options endpoint, made again only when the query or the API client changes, or on
|
|
34
|
+
* `retry`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useReferenceOptions({ staticOptions, collections, entryTypes, displayField, branch, optionSort, }: ReferenceOptionsQuery): ReferenceOptions;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { createApiClient } from '../../api/client.js';
|
|
4
|
+
import { getErrorMessage } from '../../utils/error.js';
|
|
5
|
+
import { useOptionalApiClient } from '../context/index.js';
|
|
6
|
+
/** The id a stored reference holds: the id itself, or a resolved read's `{ id, … }`. */
|
|
7
|
+
export const extractId = (val) => {
|
|
8
|
+
if (typeof val === 'string')
|
|
9
|
+
return val;
|
|
10
|
+
if (val && typeof val === 'object' && 'id' in val && typeof val.id === 'string')
|
|
11
|
+
return val.id;
|
|
12
|
+
return '';
|
|
13
|
+
};
|
|
14
|
+
/** Label for a set value the options omit (a restricted, missing or unlisted target). */
|
|
15
|
+
export function unlistedReferenceLabel(value, id) {
|
|
16
|
+
if (value && typeof value === 'object' && 'unavailable' in value && value.unavailable === true) {
|
|
17
|
+
if ('reason' in value && value.reason === 'restricted' && 'title' in value) {
|
|
18
|
+
return `Restricted entry: ${String(value.title)}`;
|
|
19
|
+
}
|
|
20
|
+
return `Missing entry (${id})`;
|
|
21
|
+
}
|
|
22
|
+
return `Unavailable entry (${id})`;
|
|
23
|
+
}
|
|
24
|
+
/** `optionSort` as the reference-options endpoint's `sort`, `sortField` and `sortDir` params. */
|
|
25
|
+
function sortParams(sort) {
|
|
26
|
+
if (sort === undefined)
|
|
27
|
+
return {};
|
|
28
|
+
if (typeof sort === 'string')
|
|
29
|
+
return { sort };
|
|
30
|
+
return {
|
|
31
|
+
sort: 'field',
|
|
32
|
+
sortField: sort.field,
|
|
33
|
+
...(sort.direction && { sortDir: sort.direction }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The entries a reference field offers: `staticOptions` as given, else one request per field to
|
|
38
|
+
* the reference-options endpoint, made again only when the query or the API client changes, or on
|
|
39
|
+
* `retry`.
|
|
40
|
+
*/
|
|
41
|
+
export function useReferenceOptions({ staticOptions, collections, entryTypes, displayField, branch, optionSort, }) {
|
|
42
|
+
// `null` outside an ApiClientProvider (some stories), so the fetch falls back to a
|
|
43
|
+
// default-configured client.
|
|
44
|
+
const contextApiClient = useOptionalApiClient();
|
|
45
|
+
const hasCollections = !!collections && collections.length > 0;
|
|
46
|
+
const hasEntryTypes = !!entryTypes && entryTypes.length > 0;
|
|
47
|
+
const needsFetch = !staticOptions && (hasCollections || hasEntryTypes);
|
|
48
|
+
const [fetched, setFetched] = useState([]);
|
|
49
|
+
const [loading, setLoading] = useState(needsFetch);
|
|
50
|
+
const [error, setError] = useState(null);
|
|
51
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
52
|
+
// A changed query starts loading in the same render, so no stale option list shows meanwhile.
|
|
53
|
+
const [prevFetchKey, setPrevFetchKey] = useState('');
|
|
54
|
+
const fetchKey = needsFetch
|
|
55
|
+
? `${collections?.join(',') ?? ''}:${entryTypes?.join(',') ?? ''}:${displayField}:${branch}:${JSON.stringify(optionSort ?? null)}`
|
|
56
|
+
: '';
|
|
57
|
+
if (fetchKey !== prevFetchKey) {
|
|
58
|
+
setPrevFetchKey(fetchKey);
|
|
59
|
+
if (needsFetch) {
|
|
60
|
+
setLoading(true);
|
|
61
|
+
setError(null);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Keyed on `fetchKey`, not the `collections`/`entryTypes` arrays, which the parent rebuilds on
|
|
65
|
+
// every render, so those and `displayField`/`branch`/`optionSort` are left out of the deps on
|
|
66
|
+
// purpose.
|
|
67
|
+
// `active` drops a response a newer request or an unmount has superseded.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!needsFetch)
|
|
70
|
+
return;
|
|
71
|
+
let active = true;
|
|
72
|
+
const apiClient = contextApiClient ?? createApiClient();
|
|
73
|
+
const params = { branch, displayField };
|
|
74
|
+
if (collections && collections.length > 0)
|
|
75
|
+
params.collections = collections.join(',');
|
|
76
|
+
if (entryTypes && entryTypes.length > 0)
|
|
77
|
+
params.entryTypes = entryTypes.join(',');
|
|
78
|
+
Object.assign(params, sortParams(optionSort));
|
|
79
|
+
apiClient.content
|
|
80
|
+
.getReferenceOptions(params)
|
|
81
|
+
.then((response) => {
|
|
82
|
+
if (!active)
|
|
83
|
+
return;
|
|
84
|
+
if (response.ok && response.data?.options) {
|
|
85
|
+
setFetched(response.data.options.map((opt) => ({
|
|
86
|
+
value: opt.id,
|
|
87
|
+
label: opt.label,
|
|
88
|
+
...(opt.noindex ? { noindex: opt.noindex } : {}),
|
|
89
|
+
})));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
setError(response.error || 'Failed to load reference options');
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.catch((err) => {
|
|
96
|
+
if (!active)
|
|
97
|
+
return;
|
|
98
|
+
console.error('Failed to load reference options:', err);
|
|
99
|
+
setError(getErrorMessage(err));
|
|
100
|
+
})
|
|
101
|
+
.finally(() => {
|
|
102
|
+
if (!active)
|
|
103
|
+
return;
|
|
104
|
+
setLoading(false);
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
active = false;
|
|
108
|
+
};
|
|
109
|
+
}, [needsFetch, fetchKey, retryCount, contextApiClient]);
|
|
110
|
+
const retry = () => {
|
|
111
|
+
setLoading(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
setRetryCount((count) => count + 1);
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
options: staticOptions ?? fetched,
|
|
117
|
+
loading: needsFetch && loading,
|
|
118
|
+
error: needsFetch ? error : null,
|
|
119
|
+
retry,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { OutlineNode } from './FormHeader.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function buildFormOutline(fields: readonly FieldConfig[], value: Record<string, unknown>, customTypes?: ReadonlySet<string>): OutlineNode[];
|
|
9
9
|
/**
|
|
10
|
-
* True when some field, at any depth, is a block field or a list of objects that the
|
|
11
|
-
* itself: a type in `customTypes` has a custom renderer.
|
|
10
|
+
* True when some field, at any depth, is a block field or a list of objects or references that the
|
|
11
|
+
* form draws itself: a type in `customTypes` has a custom renderer.
|
|
12
12
|
*/
|
|
13
13
|
export declare function hasCollapsibleList(fields: readonly FieldConfig[], customTypes?: ReadonlySet<string>): boolean;
|
|
@@ -49,8 +49,8 @@ export function buildFormOutline(fields, value, customTypes = new Set()) {
|
|
|
49
49
|
return fields.map(nodeOf);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* True when some field, at any depth, is a block field or a list of objects that the
|
|
53
|
-
* itself: a type in `customTypes` has a custom renderer.
|
|
52
|
+
* True when some field, at any depth, is a block field or a list of objects or references that the
|
|
53
|
+
* form draws itself: a type in `customTypes` has a custom renderer.
|
|
54
54
|
*/
|
|
55
55
|
export function hasCollapsibleList(fields, customTypes = new Set()) {
|
|
56
56
|
return fields.some((field) => {
|
|
@@ -58,6 +58,8 @@ export function hasCollapsibleList(fields, customTypes = new Set()) {
|
|
|
58
58
|
return false;
|
|
59
59
|
if (field.type === 'block')
|
|
60
60
|
return true;
|
|
61
|
+
if (field.type === 'reference' && field.list === true)
|
|
62
|
+
return true;
|
|
61
63
|
if (field.type === 'group') {
|
|
62
64
|
return hasCollapsibleList(field.fields, customTypes);
|
|
63
65
|
}
|
package/dist/editor/theme.js
CHANGED
|
@@ -377,6 +377,11 @@ const CANOPY_STYLES = `
|
|
|
377
377
|
[data-combobox-selected] {
|
|
378
378
|
--mantine-primary-color-filled: var(--canopy-primary-fill);
|
|
379
379
|
}
|
|
380
|
+
[data-combobox-selected] [data-canopy-option-badge] {
|
|
381
|
+
color: inherit;
|
|
382
|
+
background: transparent;
|
|
383
|
+
box-shadow: inset 0 0 0 1px currentColor;
|
|
384
|
+
}
|
|
380
385
|
.mantine-DateTimePicker-day[data-selected],
|
|
381
386
|
.mantine-DateTimePicker-pickerControl[data-selected] {
|
|
382
387
|
background-color: var(--canopy-primary-fill);
|
|
@@ -18,7 +18,7 @@ import type { EntrySchemaRegistry } from './schema/types.js';
|
|
|
18
18
|
* `RESOLVED_REFERENCE_KEYS`; a top-level or inline-group field named `unavailable`. This is also the one
|
|
19
19
|
* place the shared field-shape checks run: field names a path can spell, select fields must have options,
|
|
20
20
|
* reference fields must have `collections` or `entryTypes`, no inline groups inside object/block fields,
|
|
21
|
-
* no field-name collisions after group flattening, valid `mdxAllow`, `stampOnPublish` placement.
|
|
21
|
+
* no field-name collisions after group flattening, valid `mdxAllow`, `stampOnPublish` placement, `maxItems` placement.
|
|
22
22
|
*
|
|
23
23
|
* @example
|
|
24
24
|
* ```typescript
|
|
@@ -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, ensureStampOnPublishPlacement, } from './config/validation.js';
|
|
5
|
+
import { ensureFieldNamesSpellInPaths, ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, ensureMarkdownFieldOptions, ensureMaxItemsPlacement, 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) {
|
|
@@ -41,7 +41,7 @@ function findFieldType(fields, dottedPath) {
|
|
|
41
41
|
* `RESOLVED_REFERENCE_KEYS`; a top-level or inline-group field named `unavailable`. This is also the one
|
|
42
42
|
* place the shared field-shape checks run: field names a path can spell, select fields must have options,
|
|
43
43
|
* reference fields must have `collections` or `entryTypes`, no inline groups inside object/block fields,
|
|
44
|
-
* no field-name collisions after group flattening, valid `mdxAllow`, `stampOnPublish` placement.
|
|
44
|
+
* no field-name collisions after group flattening, valid `mdxAllow`, `stampOnPublish` placement, `maxItems` placement.
|
|
45
45
|
*
|
|
46
46
|
* @example
|
|
47
47
|
* ```typescript
|
|
@@ -102,6 +102,7 @@ export function createEntrySchemaRegistry(registry) {
|
|
|
102
102
|
ensureNoGroupsInsideComplexFields(schema);
|
|
103
103
|
ensureMarkdownFieldOptions(schema);
|
|
104
104
|
ensureStampOnPublishPlacement(schema, `Entry schema registry entry "${key}"`);
|
|
105
|
+
ensureMaxItemsPlacement(schema, `Entry schema registry entry "${key}"`);
|
|
105
106
|
ensureNoFlattenedFieldNameCollisions(schema, `entry schema "${key}"`);
|
|
106
107
|
}
|
|
107
108
|
return registry;
|
package/dist/entry-schema.d.ts
CHANGED
|
@@ -372,7 +372,8 @@ export declare const defineInlineFieldGroup: <const T extends {
|
|
|
372
372
|
*
|
|
373
373
|
* **Flat by default** — an inline group, stored at the top level of the content file with no data
|
|
374
374
|
* key of its own. `group: 'seo'` nests them under that key instead, and the same
|
|
375
|
-
* `{ group: 'seo' }` must then be
|
|
375
|
+
* `{ group: 'seo' }` must then be set as `seo` in `defineCanopyConfig`, or passed to
|
|
376
|
+
* `extractSeoFields` and the free `entryToMetadata`.
|
|
376
377
|
*
|
|
377
378
|
* @example
|
|
378
379
|
* // Flat (recommended): frontmatter carries `metaTitle:` at the top level.
|
|
@@ -461,8 +462,9 @@ export declare function defineSeoFieldGroup(opts?: SeoFieldGroupOptions & {
|
|
|
461
462
|
}): InlineSeoFieldGroup;
|
|
462
463
|
export declare function defineSeoFieldGroup<const G extends string>(opts: SeoFieldGroupOptions & {
|
|
463
464
|
/**
|
|
464
|
-
* Nest the fields under this data key instead of storing them flat. Must match
|
|
465
|
-
*
|
|
465
|
+
* Nest the fields under this data key instead of storing them flat. Must match
|
|
466
|
+
* `CanopyConfig.seo.group`, or the `group` passed to `extractSeoFields` and the free
|
|
467
|
+
* `entryToMetadata`.
|
|
466
468
|
*/
|
|
467
469
|
group: G;
|
|
468
470
|
}): NestedSeoFieldGroup<G>;
|
package/dist/entry-schema.js
CHANGED
|
@@ -101,7 +101,8 @@ export const defineInlineFieldGroup = (group) => ({ ...group, type: 'group' });
|
|
|
101
101
|
*
|
|
102
102
|
* **Flat by default** — an inline group, stored at the top level of the content file with no data
|
|
103
103
|
* key of its own. `group: 'seo'` nests them under that key instead, and the same
|
|
104
|
-
* `{ group: 'seo' }` must then be
|
|
104
|
+
* `{ group: 'seo' }` must then be set as `seo` in `defineCanopyConfig`, or passed to
|
|
105
|
+
* `extractSeoFields` and the free `entryToMetadata`.
|
|
105
106
|
*
|
|
106
107
|
* @example
|
|
107
108
|
* // Flat (recommended): frontmatter carries `metaTitle:` at the top level.
|
|
@@ -1,10 +1,26 @@
|
|
|
1
|
+
import type { ReferenceOptionSort } from './config/index.js';
|
|
1
2
|
import type { ContentStore } from './content-store.js';
|
|
2
3
|
import type { ContentIdIndex } from './content-id-index.js';
|
|
3
4
|
import { type LogicalPath } from './paths/index.js';
|
|
5
|
+
import { type SeoFieldLocation } from './static/seo.js';
|
|
4
6
|
export interface ReferenceOption {
|
|
5
7
|
id: string;
|
|
6
8
|
label: string;
|
|
7
9
|
collection: string;
|
|
10
|
+
/**
|
|
11
|
+
* Present when the target is `noindex` at `config.seo`'s location, so a page that lists only
|
|
12
|
+
* indexed entries won't show it. `fieldLabel` is the editor label of the target's own noindex
|
|
13
|
+
* field, so the picker can name the setting in the words the editor sees on that entry.
|
|
14
|
+
*/
|
|
15
|
+
noindex?: {
|
|
16
|
+
fieldLabel: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface LoadReferenceOptionsSettings {
|
|
20
|
+
/** Where entries keep their SEO fields: `CanopyConfig.seo`. */
|
|
21
|
+
seo?: SeoFieldLocation;
|
|
22
|
+
/** Default `'label'`. */
|
|
23
|
+
sort?: ReferenceOptionSort;
|
|
8
24
|
}
|
|
9
25
|
/**
|
|
10
26
|
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
@@ -24,6 +40,7 @@ export declare class ReferenceResolver {
|
|
|
24
40
|
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
25
41
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
26
42
|
* triggers a read for content they won't be allowed to see anyway.
|
|
43
|
+
* @param settings - The SEO location the `noindex` marker reads, and the option order.
|
|
27
44
|
*/
|
|
28
|
-
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
|
|
45
|
+
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean, settings?: LoadReferenceOptionsSettings): Promise<ReferenceOption[]>;
|
|
29
46
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import nodePath from 'node:path';
|
|
2
2
|
import { extractSlugFromFilename, extractEntryTypeFromFilename } from './content-id-index.js';
|
|
3
|
-
import {
|
|
3
|
+
import { sortByOrder } from './content-listing.js';
|
|
4
|
+
import { entryLogicalPath, } from './paths/index.js';
|
|
5
|
+
import { DEFAULT_SEO_FIELD_NAMES, isNoindexEntry } from './static/seo.js';
|
|
4
6
|
/**
|
|
5
7
|
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
6
8
|
* target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
|
|
@@ -20,9 +22,13 @@ export class ReferenceResolver {
|
|
|
20
22
|
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
21
23
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
22
24
|
* triggers a read for content they won't be allowed to see anyway.
|
|
25
|
+
* @param settings - The SEO location the `noindex` marker reads, and the option order.
|
|
23
26
|
*/
|
|
24
|
-
async loadReferenceOptions(collections, displayField = 'title', search, entryTypes, canAccess) {
|
|
25
|
-
const
|
|
27
|
+
async loadReferenceOptions(collections, displayField = 'title', search, entryTypes, canAccess, settings = {}) {
|
|
28
|
+
const loaded = [];
|
|
29
|
+
const sort = settings.sort ?? 'label';
|
|
30
|
+
const sortField = typeof sort === 'object' ? sort.field : undefined;
|
|
31
|
+
const noindexLabels = new NoindexFieldLabels(this.store.getSchemaItems(), settings.seo);
|
|
26
32
|
let candidates;
|
|
27
33
|
if (collections && collections.length > 0) {
|
|
28
34
|
const results = await Promise.all(collections.map((col) => this.store.getCollectionEntryPaths(col)));
|
|
@@ -60,10 +66,15 @@ export class ReferenceResolver {
|
|
|
60
66
|
if (search && !label.toLowerCase().includes(search.toLowerCase())) {
|
|
61
67
|
continue;
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
const option = { id, label, collection: location.collection };
|
|
70
|
+
if (isNoindexEntry(doc.data, settings.seo)) {
|
|
71
|
+
const entryType = extractEntryTypeFromFilename(filename) ?? '';
|
|
72
|
+
option.noindex = { fieldLabel: noindexLabels.get(location.collection, entryType) };
|
|
73
|
+
}
|
|
74
|
+
loaded.push({
|
|
75
|
+
option,
|
|
76
|
+
contentId: id,
|
|
77
|
+
sortValue: sortField === undefined ? undefined : sortValueAt(doc.data, sortField),
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
catch (error) {
|
|
@@ -75,6 +86,162 @@ export class ReferenceResolver {
|
|
|
75
86
|
continue;
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
|
-
|
|
89
|
+
if (sort === 'collection')
|
|
90
|
+
return sortByCollection(loaded, this.store.getSchemaItems());
|
|
91
|
+
if (typeof sort === 'object')
|
|
92
|
+
return sortByField(loaded, sort.direction ?? 'asc');
|
|
93
|
+
return loaded.map((l) => l.option).sort(byLabel);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const byLabel = (a, b) => a.label.localeCompare(b.label);
|
|
97
|
+
/**
|
|
98
|
+
* The value at a dotted path, read through own properties only so a path like `__proto__`
|
|
99
|
+
* reads nothing. A non-blank string, a finite number or a valid date counts as a value; a date
|
|
100
|
+
* becomes its ISO string, since md/mdx frontmatter parses an unquoted `YYYY-MM-DD` as a `Date`.
|
|
101
|
+
*/
|
|
102
|
+
function sortValueAt(data, path) {
|
|
103
|
+
let current = data;
|
|
104
|
+
for (const segment of path.split('.')) {
|
|
105
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current))
|
|
106
|
+
return undefined;
|
|
107
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment))
|
|
108
|
+
return undefined;
|
|
109
|
+
current = current[segment];
|
|
110
|
+
}
|
|
111
|
+
if (typeof current === 'number')
|
|
112
|
+
return Number.isFinite(current) ? current : undefined;
|
|
113
|
+
if (typeof current === 'string')
|
|
114
|
+
return current.trim() || undefined;
|
|
115
|
+
if (current instanceof Date) {
|
|
116
|
+
return Number.isNaN(current.getTime()) ? undefined : current.toISOString();
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Numbers before strings, so a field typed differently across entry types still sorts in one
|
|
122
|
+
* consistent order; numbers compare numerically, strings as plain text, which orders ISO dates.
|
|
123
|
+
*/
|
|
124
|
+
function compareValues(a, b) {
|
|
125
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
126
|
+
return a - b;
|
|
127
|
+
if (typeof a === 'number')
|
|
128
|
+
return -1;
|
|
129
|
+
if (typeof b === 'number')
|
|
130
|
+
return 1;
|
|
131
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Entries with no value go first under `desc` and last under `asc`: newest-first then puts an
|
|
135
|
+
* entry whose date is not yet stamped at the top, where an editor queueing it looks.
|
|
136
|
+
*/
|
|
137
|
+
function sortByField(loaded, direction) {
|
|
138
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
139
|
+
return loaded
|
|
140
|
+
.sort((a, b) => {
|
|
141
|
+
if (a.sortValue === undefined || b.sortValue === undefined) {
|
|
142
|
+
if (a.sortValue !== b.sortValue) {
|
|
143
|
+
const aMissingFirst = direction === 'desc';
|
|
144
|
+
return (a.sortValue === undefined) === aMissingFirst ? -1 : 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const byValue = compareValues(a.sortValue, b.sortValue) * sign;
|
|
149
|
+
if (byValue !== 0)
|
|
150
|
+
return byValue;
|
|
151
|
+
}
|
|
152
|
+
return byLabel(a.option, b.option);
|
|
153
|
+
})
|
|
154
|
+
.map((l) => l.option);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* One group per collection in content-tree order: a collection, then each subcollection in its
|
|
158
|
+
* parent's `order`. Within a group, options follow the collection's own `order`, then label.
|
|
159
|
+
*/
|
|
160
|
+
function sortByCollection(loaded, schemaItems) {
|
|
161
|
+
const collections = [...schemaItems].filter((item) => item.type === 'collection');
|
|
162
|
+
const byPath = new Map(collections.map((c) => [c.logicalPath, c]));
|
|
163
|
+
const children = new Map();
|
|
164
|
+
const roots = [];
|
|
165
|
+
for (const collection of collections) {
|
|
166
|
+
const parent = collection.parentPath;
|
|
167
|
+
if (parent === undefined || !byPath.has(parent)) {
|
|
168
|
+
roots.push(collection);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
children.set(parent, [...(children.get(parent) ?? []), collection]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const treeOrder = [];
|
|
175
|
+
const visit = (siblings, order) => {
|
|
176
|
+
for (const collection of sortByOrder([...siblings], order, (c) => c.name)) {
|
|
177
|
+
treeOrder.push(collection);
|
|
178
|
+
visit(children.get(collection.logicalPath) ?? [], collection.order);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
visit(roots, undefined);
|
|
182
|
+
const groups = new Map();
|
|
183
|
+
for (const item of loaded) {
|
|
184
|
+
const path = item.option.collection;
|
|
185
|
+
groups.set(path, [...(groups.get(path) ?? []), item]);
|
|
186
|
+
}
|
|
187
|
+
const result = [];
|
|
188
|
+
const appendGroup = (path, order) => {
|
|
189
|
+
const group = groups.get(path);
|
|
190
|
+
if (!group)
|
|
191
|
+
return;
|
|
192
|
+
groups.delete(path);
|
|
193
|
+
result.push(...sortByOrder(group, order, (l) => l.option.label).map((l) => l.option));
|
|
194
|
+
};
|
|
195
|
+
for (const collection of treeOrder)
|
|
196
|
+
appendGroup(collection.logicalPath, collection.order);
|
|
197
|
+
// A collection missing from the schema still gets a group, after the known ones.
|
|
198
|
+
for (const path of [...groups.keys()].sort())
|
|
199
|
+
appendGroup(path, undefined);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The editor label of each entry type's noindex field, found where `seo` says the flag lives:
|
|
204
|
+
* a top-level field (inline groups are transparent), or a field inside the object named by
|
|
205
|
+
* `seo.group`. Falls back to the field's name when the schema has no such field.
|
|
206
|
+
*/
|
|
207
|
+
class NoindexFieldLabels {
|
|
208
|
+
constructor(schemaItems, seo) {
|
|
209
|
+
this.seo = seo;
|
|
210
|
+
this.cache = new Map();
|
|
211
|
+
this.entryTypes = [...schemaItems].filter((item) => item.type === 'entry-type');
|
|
212
|
+
this.fieldName = seo?.fields?.noindex || DEFAULT_SEO_FIELD_NAMES.noindex;
|
|
213
|
+
}
|
|
214
|
+
get(collection, entryType) {
|
|
215
|
+
const key = `${collection}\0${entryType}`;
|
|
216
|
+
const cached = this.cache.get(key);
|
|
217
|
+
if (cached !== undefined)
|
|
218
|
+
return cached;
|
|
219
|
+
const schema = this.entryTypes.find((item) => item.parentPath === collection && item.name === entryType)?.schema;
|
|
220
|
+
const label = (schema && this.findLabel(schema)) ?? this.fieldName;
|
|
221
|
+
this.cache.set(key, label);
|
|
222
|
+
return label;
|
|
223
|
+
}
|
|
224
|
+
findLabel(schema) {
|
|
225
|
+
let fields = schema;
|
|
226
|
+
if (this.seo?.group) {
|
|
227
|
+
const group = findField(schema, this.seo.group);
|
|
228
|
+
fields = group?.type === 'object' ? group.fields : undefined;
|
|
229
|
+
}
|
|
230
|
+
const field = fields && findField(fields, this.fieldName);
|
|
231
|
+
return field ? (field.label ?? field.name) : undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** A field by data name, looking through inline groups, which store no key of their own. */
|
|
235
|
+
function findField(fields, name) {
|
|
236
|
+
for (const field of fields) {
|
|
237
|
+
if (field.type === 'group') {
|
|
238
|
+
const inner = findField(field.fields, name);
|
|
239
|
+
if (inner)
|
|
240
|
+
return inner;
|
|
241
|
+
}
|
|
242
|
+
else if (field.name === name) {
|
|
243
|
+
return field;
|
|
244
|
+
}
|
|
79
245
|
}
|
|
246
|
+
return undefined;
|
|
80
247
|
}
|
|
@@ -164,14 +164,14 @@ declare const updateEntryTypeInputSchema: z.ZodObject<{
|
|
|
164
164
|
default?: boolean | undefined;
|
|
165
165
|
format?: "mdx" | "md" | "json" | "yaml" | undefined;
|
|
166
166
|
label?: string | undefined;
|
|
167
|
-
schema?: string | undefined;
|
|
168
167
|
maxItems?: number | undefined;
|
|
168
|
+
schema?: string | undefined;
|
|
169
169
|
}, {
|
|
170
170
|
default?: boolean | undefined;
|
|
171
171
|
format?: "mdx" | "md" | "json" | "yaml" | undefined;
|
|
172
172
|
label?: string | undefined;
|
|
173
|
-
schema?: string | undefined;
|
|
174
173
|
maxItems?: number | undefined;
|
|
174
|
+
schema?: string | undefined;
|
|
175
175
|
}>;
|
|
176
176
|
/**
|
|
177
177
|
* Thrown when a schema mutation cannot proceed because the per-branch
|
package/dist/static/index.d.ts
CHANGED
|
@@ -101,11 +101,13 @@ type StaticBuildContext = Pick<CanopyBuildContext, 'listEntries'> & Partial<Pick
|
|
|
101
101
|
* when you actually mean to — omission should be a decision, not an oversight.
|
|
102
102
|
*
|
|
103
103
|
* `noindex` is NOT applied here: an entry that must not be advertised must still be built. Apply
|
|
104
|
-
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does)
|
|
104
|
+
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does), at the SEO
|
|
105
|
+
* location in `CanopyConfig.seo`.
|
|
105
106
|
*
|
|
106
107
|
* @example
|
|
107
|
-
* const
|
|
108
|
-
* const
|
|
108
|
+
* const ctx = await getCanopyForBuild()
|
|
109
|
+
* const entries = await collectRoutableEntries(ctx)
|
|
110
|
+
* const published = entries.filter((e) => !isNoindexEntry(e.data, ctx.services.config.seo))
|
|
109
111
|
*/
|
|
110
112
|
export declare function collectRoutableEntries<T = Record<string, unknown>>(ctx: StaticBuildContext, options?: CollectRoutableEntriesOptions<T>): Promise<RoutableEntry<T>[]>;
|
|
111
113
|
/**
|
|
@@ -206,7 +208,9 @@ export declare function warnUnsafeMarkdown(items: readonly BuildScanItem[], phas
|
|
|
206
208
|
*
|
|
207
209
|
* Fails the build rather than silently skipping the offending entry: a page that silently
|
|
208
210
|
* disappears from a static build is a worse failure mode than a red build. Lists every
|
|
209
|
-
* offending entry so one build catches every abandoned scaffold, not just the first.
|
|
211
|
+
* offending entry so one build catches every abandoned scaffold, not just the first. An
|
|
212
|
+
* over-`maxItems` list gets its own line per list, with the fix; the abandoned-scaffold advice
|
|
213
|
+
* appears only when some entry has another kind of error.
|
|
210
214
|
*/
|
|
211
215
|
export declare function assertBuildEntriesValid(items: readonly BuildScanItem[], phaseLabel: string): void;
|
|
212
216
|
/**
|