canopycms 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +91 -41
  2. package/dist/ai/generate.d.ts +3 -0
  3. package/dist/ai/generate.js +23 -33
  4. package/dist/ai/handler.js +1 -0
  5. package/dist/ai/types.d.ts +6 -11
  6. package/dist/api/reference-options.d.ts +2 -5
  7. package/dist/api/reference-options.js +21 -2
  8. package/dist/api/schema.d.ts +4 -4
  9. package/dist/build/generate-ai-content.js +1 -0
  10. package/dist/cli/cli.js +176 -66
  11. package/dist/cli/generate-ai-content.js +122 -62
  12. package/dist/cli/init.js +29 -4
  13. package/dist/config/index.d.ts +1 -1
  14. package/dist/config/schemas/config.d.ts +74 -0
  15. package/dist/config/schemas/config.js +21 -0
  16. package/dist/config/schemas/field.d.ts +30 -0
  17. package/dist/config/schemas/field.js +22 -0
  18. package/dist/config/types.d.ts +47 -8
  19. package/dist/config/validation.d.ts +6 -0
  20. package/dist/config/validation.js +33 -0
  21. package/dist/content-store.d.ts +5 -4
  22. package/dist/content-store.js +12 -7
  23. package/dist/editor/FormRenderer.js +15 -13
  24. package/dist/editor/copy.d.ts +5 -0
  25. package/dist/editor/copy.js +5 -0
  26. package/dist/editor/fields/BlockField.d.ts +2 -0
  27. package/dist/editor/fields/BlockField.js +15 -8
  28. package/dist/editor/fields/ItemList.d.ts +21 -3
  29. package/dist/editor/fields/ItemList.js +16 -8
  30. package/dist/editor/fields/NumberListField.d.ts +2 -0
  31. package/dist/editor/fields/NumberListField.js +6 -2
  32. package/dist/editor/fields/ObjectListField.js +9 -3
  33. package/dist/editor/fields/ReferenceField.d.ts +6 -7
  34. package/dist/editor/fields/ReferenceField.js +28 -122
  35. package/dist/editor/fields/ReferenceListField.d.ts +41 -0
  36. package/dist/editor/fields/ReferenceListField.js +139 -0
  37. package/dist/editor/fields/SelectField.d.ts +2 -0
  38. package/dist/editor/fields/SelectField.js +6 -3
  39. package/dist/editor/fields/StringListField.d.ts +10 -1
  40. package/dist/editor/fields/StringListField.js +18 -8
  41. package/dist/editor/fields/item-expansion.d.ts +7 -3
  42. package/dist/editor/fields/item-expansion.js +6 -4
  43. package/dist/editor/fields/list-item-keys.d.ts +6 -5
  44. package/dist/editor/fields/list-item-keys.js +45 -13
  45. package/dist/editor/fields/reference-noindex.d.ts +37 -0
  46. package/dist/editor/fields/reference-noindex.js +48 -0
  47. package/dist/editor/fields/use-reference-options.d.ts +36 -0
  48. package/dist/editor/fields/use-reference-options.js +121 -0
  49. package/dist/editor/form-outline.d.ts +2 -2
  50. package/dist/editor/form-outline.js +4 -2
  51. package/dist/editor/theme.js +5 -0
  52. package/dist/entry-schema-registry.d.ts +1 -1
  53. package/dist/entry-schema-registry.js +3 -2
  54. package/dist/entry-schema.d.ts +21 -9
  55. package/dist/entry-schema.js +5 -2
  56. package/dist/reference-resolver.d.ts +18 -1
  57. package/dist/reference-resolver.js +175 -8
  58. package/dist/schema/schema-store.d.ts +2 -2
  59. package/dist/static/index.d.ts +8 -4
  60. package/dist/static/index.js +38 -13
  61. package/dist/static/seo.d.ts +7 -5
  62. package/dist/static/seo.js +13 -6
  63. package/dist/utils/body-field.d.ts +9 -1
  64. package/dist/utils/body-field.js +18 -1
  65. package/dist/validation/entry-validator.d.ts +5 -0
  66. package/dist/validation/entry-validator.js +12 -0
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -1,27 +1,37 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { TagsInput } from '@mantine/core';
4
+ import { listFullReason } from '../copy.js';
4
5
  import { LabelledInput } from './FieldLabel.js';
5
- /** The field's description, ended as a sentence, then the hint that Enter adds. */
6
- const withEnterHint = (description) => {
6
+ /**
7
+ * `maxTags` for a TagsInput holding `count` items: never below `count`, since a paste cuts the list
8
+ * to `maxTags` and would drop stored items without a word.
9
+ */
10
+ export const tagCap = (count, maxItems) => maxItems === undefined ? undefined : Math.max(count, maxItems);
11
+ /** The field's description, ended as a sentence, then `hint`. */
12
+ export const withHint = (description, hint) => {
7
13
  const own = description?.trim();
8
14
  if (!own)
9
- return 'Press Enter to add.';
10
- return `${/[.!?]$/.test(own) ? own : `${own}.`} Press Enter to add.`;
15
+ return hint;
16
+ return `${/[.!?]$/.test(own) ? own : `${own}.`} ${hint}`;
11
17
  };
12
18
  /**
13
19
  * Editor for `type: 'string', list: true` fields.
14
20
  *
15
21
  * Uses Mantine's TagsInput: type + Enter adds an item, each item renders as a
16
22
  * removable pill, and Backspace on an empty input removes the last item. An editable list says
17
- * that Enter adds, after the field's own description.
23
+ * that Enter adds, after the field's own description, or at `maxItems` why it adds no more.
18
24
  */
19
- export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, }) => {
25
+ export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, maxItems, }) => {
20
26
  const generatedId = useId();
21
27
  const inputId = id ?? generatedId;
22
28
  return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId,
23
- // TagsInput gives no sign that Enter adds a typed item.
24
- description: readOnly ? description : withEnterHint(description), value: value, size: "sm", readOnly: readOnly, onChange: (next) => {
29
+ // TagsInput gives no sign that Enter adds a typed item, nor why it stops at `maxTags`.
30
+ description: readOnly
31
+ ? description
32
+ : withHint(description, maxItems !== undefined && value.length >= maxItems
33
+ ? listFullReason(maxItems)
34
+ : 'Press Enter to add.'), maxTags: tagCap(value.length, maxItems), value: value, size: "sm", readOnly: readOnly, onChange: (next) => {
25
35
  if (!readOnly)
26
36
  onChange(next);
27
37
  },
@@ -9,7 +9,11 @@ export interface ItemExpansion {
9
9
  * Which rows of a collapsible list are open, by list-item key (`useListItemKeys`), so a row keeps
10
10
  * its state through moves, and through a Remove and its Undo. A list inside a row that moves
11
11
  * remounts and starts over. A list's first rows start open as `EXPAND_ALL_UP_TO` says; a row added
12
- * later (Add, Duplicate) starts open. `resetKey` names the entry being edited: a new one starts
13
- * over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach every list.
12
+ * later (Add, Duplicate) starts open. With `rowsStartOpen: false`, every row starts shut, for a
13
+ * list whose collapsed rows already say all it holds. `resetKey` names the entry being edited: a
14
+ * new one starts over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach
15
+ * every list.
14
16
  */
15
- export declare function useItemExpansion(itemKeys: readonly string[], resetKey: string): ItemExpansion;
17
+ export declare function useItemExpansion(itemKeys: readonly string[], resetKey: string, { rowsStartOpen }?: {
18
+ rowsStartOpen?: boolean;
19
+ }): ItemExpansion;
@@ -6,10 +6,12 @@ const EXPAND_ALL_UP_TO = 3;
6
6
  * Which rows of a collapsible list are open, by list-item key (`useListItemKeys`), so a row keeps
7
7
  * its state through moves, and through a Remove and its Undo. A list inside a row that moves
8
8
  * remounts and starts over. A list's first rows start open as `EXPAND_ALL_UP_TO` says; a row added
9
- * later (Add, Duplicate) starts open. `resetKey` names the entry being edited: a new one starts
10
- * over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach every list.
9
+ * later (Add, Duplicate) starts open. With `rowsStartOpen: false`, every row starts shut, for a
10
+ * list whose collapsed rows already say all it holds. `resetKey` names the entry being edited: a
11
+ * new one starts over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach
12
+ * every list.
11
13
  */
12
- export function useItemExpansion(itemKeys, resetKey) {
14
+ export function useItemExpansion(itemKeys, resetKey, { rowsStartOpen = true } = {}) {
13
15
  const [toggled, setToggled] = useState(() => ({
14
16
  resetKey,
15
17
  open: new Map(),
@@ -27,7 +29,7 @@ export function useItemExpansion(itemKeys, resetKey) {
27
29
  itemKeys.forEach((key, idx) => {
28
30
  if (defaults.current.open.has(key))
29
31
  return;
30
- defaults.current.open.set(key, firstPopulation ? idx === 0 || itemKeys.length <= EXPAND_ALL_UP_TO : true);
32
+ defaults.current.open.set(key, rowsStartOpen && (firstPopulation ? idx === 0 || itemKeys.length <= EXPAND_ALL_UP_TO : true));
31
33
  });
32
34
  if (itemKeys.length > 0)
33
35
  defaults.current.started = true;
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Keys for the items of object lists and block lists. An object listed once keeps the key it
3
- * was first shown with, so an append remounts no item, a removal never hands a crashed item's
4
- * boundary to the next one, and an item put back by Undo (the same object) gets its key back. A
5
- * list whose length is unchanged (an edited item, the saved copy of the same items) keeps keys by
6
- * position. One instance serves every list a component renders, told apart by `listPath`.
2
+ * Keys for the items of object lists, block lists and reference lists. An object, or a string,
3
+ * listed once keeps the key it was first shown with, so a move carries its row's state along, an
4
+ * append remounts no item, a removal never hands a crashed item's boundary to the next one, and an
5
+ * item put back by Undo (the same object, the same string) gets its key back. A list whose length
6
+ * is unchanged (an edited item, a repointed reference, the saved copy of the same items) keeps keys
7
+ * by position. One instance serves every list a component renders, told apart by `listPath`.
7
8
  */
8
9
  export declare function useListItemKeys(): (items: readonly unknown[], listPath: string) => string[];
@@ -1,50 +1,82 @@
1
1
  import { useRef } from 'react';
2
2
  /**
3
- * Keys for the items of object lists and block lists. An object listed once keeps the key it
4
- * was first shown with, so an append remounts no item, a removal never hands a crashed item's
5
- * boundary to the next one, and an item put back by Undo (the same object) gets its key back. A
6
- * list whose length is unchanged (an edited item, the saved copy of the same items) keeps keys by
7
- * position. One instance serves every list a component renders, told apart by `listPath`.
3
+ * Keys for the items of object lists, block lists and reference lists. An object, or a string,
4
+ * listed once keeps the key it was first shown with, so a move carries its row's state along, an
5
+ * append remounts no item, a removal never hands a crashed item's boundary to the next one, and an
6
+ * item put back by Undo (the same object, the same string) gets its key back. A list whose length
7
+ * is unchanged (an edited item, a repointed reference, the saved copy of the same items) keeps keys
8
+ * by position. One instance serves every list a component renders, told apart by `listPath`.
8
9
  */
9
10
  export function useListItemKeys() {
10
11
  const listItemKeys = useRef(new WeakMap());
12
+ // Strings can't key a WeakMap, and one id may sit in two lists, so each list keeps its own.
13
+ const stringKeys = useRef(new Map());
11
14
  const lastListKeys = useRef(new Map());
15
+ const lastListItems = useRef(new Map());
12
16
  const nextListItemKey = useRef(0);
13
17
  return (items, listPath) => {
14
18
  const previous = lastListKeys.current.get(listPath);
19
+ const previousItems = lastListItems.current.get(listPath);
20
+ let ownStrings = stringKeys.current.get(listPath);
21
+ if (!ownStrings) {
22
+ ownStrings = new Map();
23
+ stringKeys.current.set(listPath, ownStrings);
24
+ }
15
25
  const occurrences = new Map();
16
26
  for (const item of items) {
17
- if (typeof item === 'object' && item !== null) {
27
+ if ((typeof item === 'object' && item !== null) || typeof item === 'string') {
18
28
  occurrences.set(item, (occurrences.get(item) ?? 0) + 1);
19
29
  }
20
30
  }
21
- // Only an object listed once has an identity; a repeated one is keyed by position.
22
- const single = (item) => typeof item === 'object' && item !== null && occurrences.get(item) === 1;
31
+ // Only an item listed once has an identity; a repeated one is keyed by position.
32
+ const single = (item) => ((typeof item === 'object' && item !== null) || typeof item === 'string') &&
33
+ occurrences.get(item) === 1;
34
+ const sameLength = previous?.length === items.length;
35
+ const ownKey = (item) => {
36
+ if (typeof item !== 'string')
37
+ return listItemKeys.current.get(item);
38
+ // A string new to a list of unchanged length took an item's place (a repoint), so it takes
39
+ // that position's key rather than one it held before.
40
+ if (sameLength && !previousItems?.includes(item))
41
+ return undefined;
42
+ return ownStrings.get(item);
43
+ };
23
44
  const claimed = new Set();
24
45
  const keys = items.map((item) => {
25
- const own = single(item) ? listItemKeys.current.get(item) : undefined;
46
+ const own = single(item) ? ownKey(item) : undefined;
26
47
  // Two objects can hold one key, an edited copy having inherited its original's.
27
48
  if (own === undefined || claimed.has(own))
28
49
  return undefined;
29
50
  claimed.add(own);
30
51
  return own;
31
52
  });
32
- // The rest inherit their position's key, never one an object present here holds.
53
+ // The rest inherit their position's key, never one an item present here holds.
33
54
  const resolved = keys.map((key, idx) => {
34
55
  if (key !== undefined)
35
56
  return key;
36
- let next = previous?.length === items.length ? previous[idx] : undefined;
57
+ let next = sameLength ? previous?.[idx] : undefined;
37
58
  if (next === undefined || claimed.has(next)) {
38
59
  nextListItemKey.current += 1;
39
60
  next = `item-${nextListItemKey.current}`;
40
61
  }
41
62
  claimed.add(next);
42
63
  const item = items[idx];
43
- if (single(item))
44
- listItemKeys.current.set(item, next);
64
+ if (single(item)) {
65
+ if (typeof item === 'string') {
66
+ // The string it replaced gives the key up, so that string returns with a key of its own.
67
+ for (const [held, key] of ownStrings)
68
+ if (key === next)
69
+ ownStrings.delete(held);
70
+ ownStrings.set(item, next);
71
+ }
72
+ else {
73
+ listItemKeys.current.set(item, next);
74
+ }
75
+ }
45
76
  return next;
46
77
  });
47
78
  lastListKeys.current.set(listPath, resolved);
79
+ lastListItems.current.set(listPath, items);
48
80
  return resolved;
49
81
  };
50
82
  }
@@ -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 form draws
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 form draws
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
  }
@@ -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;
@@ -48,7 +48,10 @@ type InferableField = {
48
48
  entryTypes?: readonly string[];
49
49
  /** For reference fields: collection paths to scope the search. */
50
50
  collections?: readonly string[];
51
- /** For reference fields: resolve the target's body too. See ReferenceFieldConfig.includeBody. */
51
+ /**
52
+ * For reference fields: resolve the target's body too. Without it the `resolvedSchema`'s
53
+ * `isBody` field is optional in the resolved type. See ReferenceFieldConfig.includeBody.
54
+ */
52
55
  includeBody?: boolean;
53
56
  };
54
57
  /**
@@ -79,7 +82,9 @@ export interface ResolvedReferenceMeta {
79
82
  }
80
83
  /**
81
84
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
82
- * embed it, then the reserved metadata.
85
+ * embed it, then the reserved metadata. A link-only reference carries no body for any format:
86
+ * an md/mdx target passes none here, and a json/yaml target's `data` arrives without its
87
+ * `isBody` field (`linkOnlyReferenceData`), so both formats resolve alike.
83
88
  *
84
89
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
85
90
  * a content field cannot shadow the real content ID, which the write boundary recovers from
@@ -272,7 +277,7 @@ type FieldValue<F extends InferableField> = F extends {
272
277
  } ? ScalarValue<F, SelectValue<F>> : F extends {
273
278
  type: 'reference';
274
279
  resolvedSchema: infer S;
275
- } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>> & ResolvedReferenceMeta & {
280
+ } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>, F['includeBody'] extends true ? false : true> & ResolvedReferenceMeta & {
276
281
  unavailable?: undefined;
277
282
  }) | UnavailableReference | null> : F extends {
278
283
  type: 'reference';
@@ -298,11 +303,16 @@ type FieldValue<F extends InferableField> = F extends {
298
303
  *
299
304
  * Works with any structurally compatible array; importing FieldConfig is not required.
300
305
  */
301
- type InferContentShape<Fields extends readonly InferableField[]> = Simplify<{
302
- [F in FlattenInlineGroups<Fields>[number] as F['required'] extends false ? never : F['name']]: FieldValue<F>;
306
+ type InferContentShape<Fields extends readonly InferableField[], BodyOptional extends boolean = false> = Simplify<{
307
+ [F in FlattenInlineGroups<Fields>[number] as IsOptionalKey<F, BodyOptional> extends true ? never : F['name']]: FieldValue<F>;
303
308
  } & {
304
- [F in FlattenInlineGroups<Fields>[number] as F['required'] extends false ? F['name'] : never]?: FieldValue<F>;
309
+ [F in FlattenInlineGroups<Fields>[number] as IsOptionalKey<F, BodyOptional> extends true ? F['name'] : never]?: FieldValue<F>;
305
310
  }>;
311
+ /**
312
+ * Whether InferContentShape gives a field `?:`: an explicit `required: false`, or, with
313
+ * `BodyOptional` (a reference target resolved without `includeBody`), the `isBody` field.
314
+ */
315
+ type IsOptionalKey<F extends InferableField, BodyOptional extends boolean> = F['required'] extends false ? true : BodyOptional extends true ? F['isBody'] extends true ? true : false : false;
306
316
  /** Define an entry schema's field array with literal inference, without sprinkling `as const`. */
307
317
  export declare const defineEntrySchema: <const T extends readonly InferableField[]>(fields: T) => T;
308
318
  /** Content shape derived from a `defineEntrySchema` result. */
@@ -362,7 +372,8 @@ export declare const defineInlineFieldGroup: <const T extends {
362
372
  *
363
373
  * **Flat by default** — an inline group, stored at the top level of the content file with no data
364
374
  * key of its own. `group: 'seo'` nests them under that key instead, and the same
365
- * `{ group: 'seo' }` must then be passed to `extractSeoFields` / `entryToMetadata`.
375
+ * `{ group: 'seo' }` must then be set as `seo` in `defineCanopyConfig`, or passed to
376
+ * `extractSeoFields` and the free `entryToMetadata`.
366
377
  *
367
378
  * @example
368
379
  * // Flat (recommended): frontmatter carries `metaTitle:` at the top level.
@@ -451,8 +462,9 @@ export declare function defineSeoFieldGroup(opts?: SeoFieldGroupOptions & {
451
462
  }): InlineSeoFieldGroup;
452
463
  export declare function defineSeoFieldGroup<const G extends string>(opts: SeoFieldGroupOptions & {
453
464
  /**
454
- * Nest the fields under this data key instead of storing them flat. Must match the `group`
455
- * option passed to `extractSeoFields` / `entryToMetadata`.
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`.
456
468
  */
457
469
  group: G;
458
470
  }): NestedSeoFieldGroup<G>;
@@ -12,7 +12,9 @@ import { DEFAULT_SEO_FIELD_NAMES } from './static/seo.js';
12
12
  export const RESOLVED_REFERENCE_KEYS = ['id', 'slug', 'collection', 'urlPath'];
13
13
  /**
14
14
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
15
- * embed it, then the reserved metadata.
15
+ * embed it, then the reserved metadata. A link-only reference carries no body for any format:
16
+ * an md/mdx target passes none here, and a json/yaml target's `data` arrives without its
17
+ * `isBody` field (`linkOnlyReferenceData`), so both formats resolve alike.
16
18
  *
17
19
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
18
20
  * a content field cannot shadow the real content ID, which the write boundary recovers from
@@ -99,7 +101,8 @@ export const defineInlineFieldGroup = (group) => ({ ...group, type: 'group' });
99
101
  *
100
102
  * **Flat by default** — an inline group, stored at the top level of the content file with no data
101
103
  * key of its own. `group: 'seo'` nests them under that key instead, and the same
102
- * `{ group: 'seo' }` must then be passed to `extractSeoFields` / `entryToMetadata`.
104
+ * `{ group: 'seo' }` must then be set as `seo` in `defineCanopyConfig`, or passed to
105
+ * `extractSeoFields` and the free `entryToMetadata`.
103
106
  *
104
107
  * @example
105
108
  * // Flat (recommended): frontmatter carries `metaTitle:` at the top level.