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
@@ -33,6 +33,7 @@ export declare const imageFieldSchema: z.ZodObject<{
33
33
  list: z.ZodOptional<z.ZodBoolean>;
34
34
  isTitle: z.ZodOptional<z.ZodBoolean>;
35
35
  isBody: z.ZodOptional<z.ZodBoolean>;
36
+ maxItems: z.ZodOptional<z.ZodNumber>;
36
37
  } & {
37
38
  type: z.ZodLiteral<"image">;
38
39
  aspect: z.ZodOptional<z.ZodString>;
@@ -46,6 +47,7 @@ export declare const imageFieldSchema: z.ZodObject<{
46
47
  list?: boolean | undefined;
47
48
  isTitle?: boolean | undefined;
48
49
  isBody?: boolean | undefined;
50
+ maxItems?: number | undefined;
49
51
  aspect?: string | undefined;
50
52
  altOptional?: boolean | undefined;
51
53
  }, {
@@ -57,9 +59,16 @@ export declare const imageFieldSchema: z.ZodObject<{
57
59
  list?: boolean | undefined;
58
60
  isTitle?: boolean | undefined;
59
61
  isBody?: boolean | undefined;
62
+ maxItems?: number | undefined;
60
63
  aspect?: string | undefined;
61
64
  altOptional?: boolean | undefined;
62
65
  }>;
66
+ /**
67
+ * A dotted field path for `optionSort: { field }`: non-empty segments, so `'a..b'` and `'.a'`
68
+ * fail at config time rather than sorting on a value no entry has.
69
+ * @internal Exported for the reference-options endpoint, which validates `sortField` the same way.
70
+ */
71
+ export declare const referenceSortFieldSchema: z.ZodString;
63
72
  /** @internal Exported for tests. */
64
73
  export declare const referenceFieldSchema: z.ZodObject<{
65
74
  name: z.ZodString;
@@ -69,6 +78,7 @@ export declare const referenceFieldSchema: z.ZodObject<{
69
78
  list: z.ZodOptional<z.ZodBoolean>;
70
79
  isTitle: z.ZodOptional<z.ZodBoolean>;
71
80
  isBody: z.ZodOptional<z.ZodBoolean>;
81
+ maxItems: z.ZodOptional<z.ZodNumber>;
72
82
  } & {
73
83
  type: z.ZodLiteral<"reference">;
74
84
  collections: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -85,6 +95,16 @@ export declare const referenceFieldSchema: z.ZodObject<{
85
95
  label: string;
86
96
  }>]>, "many">>;
87
97
  includeBody: z.ZodOptional<z.ZodBoolean>;
98
+ optionSort: z.ZodOptional<z.ZodUnion<[z.ZodEnum<["label", "collection"]>, z.ZodObject<{
99
+ field: z.ZodString;
100
+ direction: z.ZodOptional<z.ZodEnum<["asc", "desc"]>>;
101
+ }, "strict", z.ZodTypeAny, {
102
+ field: string;
103
+ direction?: "asc" | "desc" | undefined;
104
+ }, {
105
+ field: string;
106
+ direction?: "asc" | "desc" | undefined;
107
+ }>]>>;
88
108
  }, "strip", z.ZodTypeAny, {
89
109
  name: string;
90
110
  type: "reference";
@@ -98,10 +118,15 @@ export declare const referenceFieldSchema: z.ZodObject<{
98
118
  list?: boolean | undefined;
99
119
  isTitle?: boolean | undefined;
100
120
  isBody?: boolean | undefined;
121
+ maxItems?: number | undefined;
101
122
  collections?: string[] | undefined;
102
123
  entryTypes?: string[] | undefined;
103
124
  displayField?: string | undefined;
104
125
  includeBody?: boolean | undefined;
126
+ optionSort?: "collection" | "label" | {
127
+ field: string;
128
+ direction?: "asc" | "desc" | undefined;
129
+ } | undefined;
105
130
  }, {
106
131
  name: string;
107
132
  type: "reference";
@@ -115,10 +140,15 @@ export declare const referenceFieldSchema: z.ZodObject<{
115
140
  list?: boolean | undefined;
116
141
  isTitle?: boolean | undefined;
117
142
  isBody?: boolean | undefined;
143
+ maxItems?: number | undefined;
118
144
  collections?: string[] | undefined;
119
145
  entryTypes?: string[] | undefined;
120
146
  displayField?: string | undefined;
121
147
  includeBody?: boolean | undefined;
148
+ optionSort?: "collection" | "label" | {
149
+ field: string;
150
+ direction?: "asc" | "desc" | undefined;
151
+ } | undefined;
122
152
  }>;
123
153
  export declare const blockSchema: z.ZodObject<{
124
154
  name: z.ZodString;
@@ -12,6 +12,9 @@ const fieldBaseSchema = z.object({
12
12
  list: z.boolean().optional(),
13
13
  isTitle: z.boolean().optional(),
14
14
  isBody: z.boolean().optional(),
15
+ // Listed so a parse keeps it (zod strips unknown keys); which fields may carry it is
16
+ // `ensureMaxItemsPlacement`'s call (config/validation.ts).
17
+ maxItems: z.number().int().positive().optional(),
15
18
  });
16
19
  export const selectOptionSchema = z.union([
17
20
  z.string(),
@@ -94,6 +97,14 @@ const selectFieldSchema = fieldBaseSchema.extend({
94
97
  type: z.literal('select'),
95
98
  options: z.array(selectOptionSchema).min(1),
96
99
  });
100
+ /**
101
+ * A dotted field path for `optionSort: { field }`: non-empty segments, so `'a..b'` and `'.a'`
102
+ * fail at config time rather than sorting on a value no entry has.
103
+ * @internal Exported for the reference-options endpoint, which validates `sortField` the same way.
104
+ */
105
+ export const referenceSortFieldSchema = z
106
+ .string()
107
+ .regex(/^[^.]+(\.[^.]+)*$/, 'must be a field name or a dotted path such as "meta.publishedAt"');
97
108
  // At least one of `collections` or `entryTypes` must be specified (enforced by config validation).
98
109
  /** @internal Exported for tests. */
99
110
  export const referenceFieldSchema = fieldBaseSchema.extend({
@@ -107,6 +118,17 @@ export const referenceFieldSchema = fieldBaseSchema.extend({
107
118
  // runtime-consumed flag and the feature would no-op with no error. (`resolvedSchema` is
108
119
  // absent on purpose — it is type-inference-only and stripping it changes no behavior.)
109
120
  includeBody: z.boolean().optional(),
121
+ optionSort: z
122
+ .union([
123
+ z.enum(['label', 'collection']),
124
+ z
125
+ .object({
126
+ field: referenceSortFieldSchema,
127
+ direction: z.enum(['asc', 'desc']).optional(),
128
+ })
129
+ .strict(),
130
+ ])
131
+ .optional(),
110
132
  });
111
133
  // Use a mutable holder to enable forward references in recursive z.lazy() closures.
112
134
  // blockSchema/objectFieldSchema reference fieldHolder[0] via z.lazy, which is resolved
@@ -9,6 +9,7 @@ import type { LogicalPath, ContentId } from '../paths/types.js';
9
9
  import type { PermissionPath } from '../authorization/types.js';
10
10
  import type { EntryLinkUrlResolver } from '../entry-link-resolver.js';
11
11
  import type { CropRect } from '../assets/transform-directives.js';
12
+ import type { SeoFieldLocation } from '../static/seo.js';
12
13
  declare const scalarFieldTypes: readonly ["string", "number", "boolean", "datetime", "code"];
13
14
  export declare const markdownFieldTypes: readonly ["markdown", "mdx"];
14
15
  export declare const primitiveFieldTypes: readonly ["string", "number", "boolean", "datetime", "code", "markdown", "mdx"];
@@ -45,12 +46,20 @@ interface BaseFieldConfig {
45
46
  /** Mark this field as the display title for entries using this schema. At most one field per schema. */
46
47
  isTitle?: boolean;
47
48
  /**
48
- * Mark this field as the body content for md/mdx entries.
49
- * The field must be type 'markdown' or 'mdx'. At most one per schema.
50
- * When present, the file's markdown content is mapped to this field,
51
- * making TypeFromEntrySchema include it without manual `& { body: string }`.
49
+ * Mark this field as the entry's body. The field must be type 'markdown' or 'mdx'. At most one
50
+ * per schema. On an md/mdx entry, the file's markdown content is mapped to this field, making
51
+ * TypeFromEntrySchema include it without manual `& { body: string }`. On a json/yaml entry, it
52
+ * is the prose a link-only reference omits. Either way a reference carries it only with
53
+ * `includeBody: true`.
52
54
  */
53
55
  isBody?: boolean;
56
+ /**
57
+ * On a `list: true` field or a `block` field: the most items the list may hold. The editor
58
+ * stops adding at the cap, saving over it is refused, and a static build fails on an entry
59
+ * over it, so adding a cap to a list that already holds more fails the build until the
60
+ * content is fixed. `ensureMaxItemsPlacement` (`validation.ts`) refuses it elsewhere in a registry schema.
61
+ */
62
+ maxItems?: number;
54
63
  }
55
64
  interface PrimitiveFieldConfig extends BaseFieldConfig {
56
65
  type: (typeof scalarFieldTypes)[number];
@@ -117,7 +126,9 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
117
126
  options?: ReferenceOption[];
118
127
  /**
119
128
  * Include the referenced entry's body in the resolved value, under that entry type's own
120
- * body field name (`isBody: true`, else `body`). Defaults to `false`.
129
+ * body field name (`isBody: true`, else `body`). Defaults to `false`. The body is an md/mdx
130
+ * target's markdown content, or a json/yaml target's `isBody` field; a json/yaml field merely
131
+ * named `body` is ordinary data and always resolves.
121
132
  *
122
133
  * This is the embed-vs-link distinction, declared where it is actually known. A reference
123
134
  * that **embeds** its target — a shared call-to-action block rendered inline — needs the
@@ -131,12 +142,31 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
131
142
  * contains both kinds: a page with a shared CTA *and* a related-posts list cannot be served
132
143
  * by a single call-level setting, and a call-level flag would mean the same field resolved
133
144
  * to different shapes in different places.
134
- *
135
- * Only affects formats that HAVE a separate body — md and mdx. A json/yaml target's whole
136
- * document is already its data, so this is a no-op there.
137
145
  */
138
146
  includeBody?: boolean;
147
+ /** Order of the picker's options. Default `'label'`. See {@link ReferenceOptionSort}. */
148
+ optionSort?: ReferenceOptionSort;
139
149
  }
150
+ /**
151
+ * How a reference picker orders its options. Every mode runs after the search filter.
152
+ *
153
+ * - `'label'`: alphabetical by label.
154
+ * - `'collection'`: one group per collection, in content-tree order (a collection, then its
155
+ * subcollections), each in its collection's own order, then by label.
156
+ * - `{ field, direction }`: by the target's value at `field`, a dotted path for a nested field
157
+ * (`'meta.publishedAt'`). Numbers compare as numbers and sort before strings (`'desc'` reverses that too); strings compare
158
+ * as plain text, so point it at an ISO date (`YYYY-MM-DD`; unquoted frontmatter dates count):
159
+ * a display date like `'March 4'` sorts by month name. An entry with no value sorts first
160
+ * under `'desc'`, last under `'asc'` (the default). Ties go by label.
161
+ *
162
+ * @example
163
+ * // Newest first, with entries not yet given a date at the top.
164
+ * optionSort: { field: 'publishedAt', direction: 'desc' }
165
+ */
166
+ export type ReferenceOptionSort = 'label' | 'collection' | {
167
+ field: string;
168
+ direction?: 'asc' | 'desc';
169
+ };
140
170
  /**
141
171
  * Image field config. The value is a structured object (see `ImageFieldValue`
142
172
  * below), not a bare URL string.
@@ -460,6 +490,8 @@ export interface CanopyConfig {
460
490
  validateEntry?: ValidateEntryHook;
461
491
  /** Site default for every field checked as MDX — see {@link CanopyConfigInput.mdxAllow}. */
462
492
  mdxAllow?: MdxAllowlist;
493
+ /** Where entries keep their SEO fields — see {@link CanopyConfigInput.seo}. */
494
+ seo?: SeoFieldLocation;
463
495
  /** Dev-mode-only behavior — see {@link CanopyConfigInput.dev}. */
464
496
  dev?: DevConfig;
465
497
  }
@@ -549,6 +581,13 @@ export interface CanopyConfigInput {
549
581
  validateEntry?: ValidateEntryHook;
550
582
  /** What every field checked as MDX accepts; a field's own `mdxAllow` replaces it key by key. */
551
583
  mdxAllow?: MdxAllowlist;
584
+ /**
585
+ * Where entries keep their SEO fields: `group` when `defineSeoFieldGroup({ group })` nests
586
+ * them, `fields` for renamed ones. Omit for the flat default. Every surface that reads an
587
+ * entry's `noindex` reads it here: the sitemap and page metadata helpers `createNextCanopyContext`
588
+ * returns, AI content and the editor's reference picker.
589
+ */
590
+ seo?: SeoFieldLocation;
552
591
  /** Dev-mode-only behavior (content-sync divergence detection). Ignored when mode !== 'dev'. */
553
592
  dev?: DevConfig;
554
593
  }
@@ -62,6 +62,12 @@ export declare const ensureNoGroupsInsideComplexFields: (fields: unknown) => voi
62
62
  * never be saved empty, so it would never be stamped. Throws on the first problem.
63
63
  */
64
64
  export declare const ensureStampOnPublishPlacement: (fields: unknown, scopeLabel?: string) => void;
65
+ /**
66
+ * Check that `maxItems` is a positive whole number on a field that holds items: a `list: true`
67
+ * field or a `block` field. Recurses into groups, objects and block templates like
68
+ * `ensureStampOnPublishPlacement`. Throws on the first problem.
69
+ */
70
+ export declare const ensureMaxItemsPlacement: (fields: unknown, scopeLabel?: string) => void;
65
71
  /**
66
72
  * Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
67
73
  * `mdxAllow` and `executable` against each other. Throws on the first problem.
@@ -294,6 +294,39 @@ export const ensureStampOnPublishPlacement = (fields, scopeLabel = 'entry schema
294
294
  };
295
295
  check(fields, undefined);
296
296
  };
297
+ /**
298
+ * Check that `maxItems` is a positive whole number on a field that holds items: a `list: true`
299
+ * field or a `block` field. Recurses into groups, objects and block templates like
300
+ * `ensureStampOnPublishPlacement`. Throws on the first problem.
301
+ */
302
+ export const ensureMaxItemsPlacement = (fields, scopeLabel = 'entry schema') => {
303
+ const refuse = (f, why) => {
304
+ throw new Error(`${scopeLabel}: field "${f.name ?? 'unknown'}" has maxItems, ${why}`);
305
+ };
306
+ const check = (scopeFields) => {
307
+ if (!Array.isArray(scopeFields))
308
+ return;
309
+ for (const field of scopeFields) {
310
+ const f = field;
311
+ if (f?.maxItems !== undefined) {
312
+ if (typeof f.maxItems !== 'number' || !Number.isInteger(f.maxItems) || f.maxItems < 1) {
313
+ refuse(f, 'which must be a positive whole number');
314
+ }
315
+ if (f.list !== true && f.type !== 'block') {
316
+ refuse(f, 'but it is not a list; only a `list: true` field or a block field holds items');
317
+ }
318
+ }
319
+ if (f?.type === 'group' || f?.type === 'object')
320
+ check(f.fields);
321
+ if (f?.type === 'block' && Array.isArray(f.templates)) {
322
+ for (const template of f.templates) {
323
+ check(template.fields);
324
+ }
325
+ }
326
+ }
327
+ };
328
+ check(fields);
329
+ };
297
330
  /**
298
331
  * Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
299
332
  * `mdxAllow` and `executable` against each other. Throws on the first problem.
@@ -557,10 +557,11 @@ export declare class ContentStore {
557
557
  * Measured (2000 occurrences of one target, local disk, the friendliest case for NOT
558
558
  * caching): cloning a snippet-sized target is ~63x cheaper than re-reading and parsing it, a
559
559
  * 265KB JSON target ~0.8x -- a wash at the pathological end, never a blow-up, and on the
560
- * EFS/NFS this targets the syscall the memo removes dominates both. An md/mdx target
561
- * resolves to its FRONTMATTER unless the field sets `includeBody`, which is why
562
- * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
563
- * cloned once per referencing entry.
560
+ * EFS/NFS this targets the syscall the memo removes dominates both. A target resolves
561
+ * without its body (an md/mdx target to its frontmatter, a json/yaml one without its `isBody`
562
+ * field) unless the field sets `includeBody`, which is why `includeBody` is opt-in per
563
+ * field: an embedded body sits inside the memoized object and is cloned once per referencing
564
+ * entry.
564
565
  */
565
566
  private resolveSingleReference;
566
567
  /**
@@ -20,7 +20,7 @@ import { serializeFrontmatter, serializeYaml } from './utils/content-serialize.j
20
20
  import { serializeJson } from './utils/json-source-splice.js';
21
21
  import { withLock } from './utils/async-mutex.js';
22
22
  import { assertContentWriteLockHeld, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
23
- import { findBodyFieldName } from './utils/body-field.js';
23
+ import { findBodyFieldName, linkOnlyReferenceData } from './utils/body-field.js';
24
24
  import { withItemKeys } from './item-keys.js';
25
25
  import { buildMissingReference, buildResolvedReference, buildRestrictedReference, } from './entry-schema.js';
26
26
  import { warnDanglingReference } from './dangling-reference-log.js';
@@ -1611,10 +1611,11 @@ export class ContentStore {
1611
1611
  * Measured (2000 occurrences of one target, local disk, the friendliest case for NOT
1612
1612
  * caching): cloning a snippet-sized target is ~63x cheaper than re-reading and parsing it, a
1613
1613
  * 265KB JSON target ~0.8x -- a wash at the pathological end, never a blow-up, and on the
1614
- * EFS/NFS this targets the syscall the memo removes dominates both. An md/mdx target
1615
- * resolves to its FRONTMATTER unless the field sets `includeBody`, which is why
1616
- * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
1617
- * cloned once per referencing entry.
1614
+ * EFS/NFS this targets the syscall the memo removes dominates both. A target resolves
1615
+ * without its body (an md/mdx target to its frontmatter, a json/yaml one without its `isBody`
1616
+ * field) unless the field sets `includeBody`, which is why `includeBody` is opt-in per
1617
+ * field: an embedded body sits inside the memoized object and is cloned once per referencing
1618
+ * entry.
1618
1619
  */
1619
1620
  resolveSingleReference(id, idIndex, includeBody, cache, access) {
1620
1621
  if (!cache)
@@ -1738,9 +1739,13 @@ export class ContentStore {
1738
1739
  //
1739
1740
  // `'body' in doc` narrows the ContentDocument union to its markdown variant, so
1740
1741
  // `doc.body`/`doc.bodyFieldName` are reachable at all -- a type guard, not a redundant
1741
- // runtime check. Only a field that asked to EMBED its target passes a body at all.
1742
+ // runtime check. Only a field that asked to EMBED its target passes a body at all, and
1743
+ // only such a field keeps a json/yaml target's `isBody` field.
1742
1744
  const bodyForEmbed = includeBody && 'body' in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : undefined;
1743
- return buildResolvedReference(doc.data, meta, bodyForEmbed);
1745
+ const data = includeBody
1746
+ ? doc.data
1747
+ : linkOnlyReferenceData(doc.data, doc.format, this.entryTypeOf(location.collection, location.relativePath)?.schema);
1748
+ return buildResolvedReference(data, meta, bodyForEmbed);
1744
1749
  }
1745
1750
  catch (error) {
1746
1751
  // Index hit but the file is gone — the typical symptom of an external
@@ -16,6 +16,7 @@ import { DateTimeField } from './fields/DateTimeField.js';
16
16
  import { BlockField } from './fields/BlockField.js';
17
17
  import { SelectField } from './fields/SelectField.js';
18
18
  import { ReferenceField } from './fields/ReferenceField.js';
19
+ import { ReferenceListField } from './fields/ReferenceListField.js';
19
20
  import { CodeField } from './fields/CodeField.js';
20
21
  import { ImageField } from './fields/ImageField.js';
21
22
  import { ObjectField } from './fields/ObjectField.js';
@@ -201,7 +202,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
201
202
  switch (field.type) {
202
203
  case 'string':
203
204
  if (field.list) {
204
- return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
205
+ return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
205
206
  }
206
207
  return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
207
208
  case 'boolean':
@@ -210,7 +211,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
210
211
  if (field.list) {
211
212
  return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
212
213
  ? currentValue.filter((v) => typeof v === 'number')
213
- : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
214
+ : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
214
215
  }
215
216
  return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
216
217
  case 'datetime':
@@ -229,22 +230,23 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
229
230
  ? Array.isArray(currentValue)
230
231
  ? currentValue
231
232
  : []
232
- : (currentValue ?? ''), multiple: isMulti, onChange: (next) => update(next), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
233
+ : (currentValue ?? ''), multiple: isMulti, onChange: (next) => update(next), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: isMulti ? selectField.maxItems : undefined }, fieldKey(path)));
233
234
  }
234
235
  case 'reference': {
235
236
  const referenceField = field;
236
237
  const staticOptions = referenceField.options
237
238
  ? normalizeOptions(referenceField.options)
238
239
  : undefined;
239
- const isMulti = Boolean(referenceField.list);
240
- return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions?.map((opt) => ({
241
- label: opt.label,
242
- value: opt.value,
243
- })), collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, value: isMulti
244
- ? Array.isArray(currentValue)
245
- ? currentValue
246
- : []
247
- : (currentValue ?? ''), multiple: isMulti, onChange: (next) => update(next), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
240
+ if (referenceField.list) {
241
+ return wrapWithComments(_jsx(ReferenceListField, { id: fieldId, label: label, description: field.description, required: field.required, options: staticOptions, collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, optionSort: referenceField.optionSort, value: Array.isArray(currentValue) ? currentValue : [], onChange: (next) => update(next), path: path, canopyPath: canopyPath, readOnly: readOnly, resetKey: boundaryResetKey, maxItems: referenceField.maxItems, itemError: (index) => fieldErrors?.[normalizeCanopyPath([...path, index])], onRemoved: (index, id, title) => onRemoved?.({
242
+ kind: 'list-item',
243
+ listPath: path,
244
+ index,
245
+ item: id,
246
+ label: `"${title}" from ${label}`,
247
+ }) }, fieldKey(path)));
248
+ }
249
+ return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions, collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, optionSort: referenceField.optionSort, value: currentValue ?? '', onChange: (next) => update(next), dataCanopyField: canopyPath, readOnly: readOnly }, fieldKey(path)));
248
250
  }
249
251
  case 'image': {
250
252
  const imageField = field;
@@ -256,7 +258,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
256
258
  }
257
259
  case 'block': {
258
260
  const blockField = field;
259
- return wrapWithComments(_jsx(BlockField, { label: label, required: field.required, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, resetKey: boundaryResetKey, hasErrors: rowHasErrors(path), onRemoved: (index, block) => {
261
+ return wrapWithComments(_jsx(BlockField, { label: label, required: field.required, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, resetKey: boundaryResetKey, hasErrors: rowHasErrors(path), maxItems: blockField.maxItems, onRemoved: (index, block) => {
260
262
  const template = blockField.templates.find((t) => t.name === block.template);
261
263
  onRemoved?.({
262
264
  kind: 'list-item',
@@ -19,3 +19,8 @@ export declare const EDITOR_ACTIONS: {
19
19
  readonly changeUrl: "Change URL";
20
20
  readonly changeSlug: "Change slug";
21
21
  };
22
+ /**
23
+ * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
24
+ * `one` and `many` name what the list holds.
25
+ */
26
+ export declare const listFullReason: (maxItems: number, one?: string, many?: string) => string;
@@ -19,3 +19,8 @@ export const EDITOR_ACTIONS = {
19
19
  changeUrl: 'Change URL',
20
20
  changeSlug: 'Change slug',
21
21
  };
22
+ /**
23
+ * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
24
+ * `one` and `many` name what the list holds.
25
+ */
26
+ export const listFullReason = (maxItems, one = 'item', many = 'items') => `This list holds at most ${maxItems} ${maxItems === 1 ? one : many}. Remove one to add another.`;
@@ -26,6 +26,8 @@ export interface BlockFieldProps {
26
26
  hasErrors?: (index: number) => boolean;
27
27
  /** Told of each block Remove takes out, so the caller can offer Undo. */
28
28
  onRemoved?: (index: number, block: BlockInstance) => void;
29
+ /** The most blocks the list holds; at it, every add control is disabled and says why. */
30
+ maxItems?: number;
29
31
  }
30
32
  export declare const BlockField: React.FC<BlockFieldProps>;
31
33
  export {};
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React, { useId, useRef, useState } from 'react';
4
- import { ActionIcon, Box, Button, Group, Menu, Paper, ScrollArea, Stack, Text, Tooltip, } from '@mantine/core';
4
+ import { ActionIcon, Box, Button, Group, Menu, Paper, ScrollArea, Stack, Text, Tooltip, VisuallyHidden, } from '@mantine/core';
5
5
  import { useMediaQuery } from '@mantine/hooks';
6
6
  import { IconPlus } from '@tabler/icons-react';
7
7
  import { ITEM_KEY, mintItemKey, withFreshItemKeys } from '../../item-keys.js';
8
8
  import { isPlainRecord } from '../../validation/field-traversal.js';
9
9
  import { formatCanopyPath } from '../canopy-path.js';
10
+ import { listFullReason } from '../copy.js';
10
11
  import { useFieldCommentControl } from '../comments/field-comment-context.js';
11
12
  import { ExpandAllButton } from './ExpandAllButton.js';
12
13
  import { groupDescriptionProps } from './FieldDescription.js';
@@ -22,12 +23,13 @@ const ADD_SECTION = 'Add section';
22
23
  * The block templates as a menu, each with its description, so an editor picks a section by
23
24
  * what it is for. `target` is the control that opens it.
24
25
  */
25
- const TemplateMenu = ({ templates, onPick, onOpenChange, target }) => (_jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "md", width: 320, onChange: onOpenChange, children: [target, _jsx(Menu.Dropdown, { "data-testid": "block-template-menu", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "auto", children: templates.map((template) => (_jsxs(Menu.Item, { onClick: () => onPick(template.name), "data-testid": `block-template-${template.name}`, children: [_jsx(Text, { size: "sm", fw: 500, children: schemaLabel(template) }), template.description && (_jsx(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'normal' }, children: template.description }))] }, template.name))) }) })] }));
26
+ const TemplateMenu = ({ templates, onPick, onOpenChange, disabled, target }) => (_jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "md", width: 320, onChange: onOpenChange, disabled: disabled, children: [target, _jsx(Menu.Dropdown, { "data-testid": "block-template-menu", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "auto", children: templates.map((template) => (_jsxs(Menu.Item, { onClick: () => onPick(template.name), "data-testid": `block-template-${template.name}`, children: [_jsx(Text, { size: "sm", fw: 500, children: schemaLabel(template) }), template.description && (_jsx(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'normal' }, children: template.description }))] }, template.name))) }) })] }));
26
27
  /**
27
28
  * The gap before a block. Its add control shows while the gap is hovered, the control is focused
28
29
  * or its menu is open, and stays in the tab order, so a keyboard reaches it before each block.
29
30
  */
30
- const InsertBetween = ({ templates, onPick }) => {
31
+ const InsertBetween = ({ templates, onPick, blockedReason }) => {
32
+ const reasonId = useId();
31
33
  const [hovered, setHovered] = useState(false);
32
34
  const [focused, setFocused] = useState(false);
33
35
  const [opened, setOpened] = useState(false);
@@ -35,9 +37,9 @@ const InsertBetween = ({ templates, onPick }) => {
35
37
  const noHover = useMediaQuery('(hover: none)');
36
38
  const shown = hovered || focused || opened;
37
39
  const opacity = shown ? 1 : noHover ? 0.6 : 0;
38
- return (_jsxs(Box, { h: 20, pos: "relative", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), "data-testid": "block-insert-between", children: [_jsx(Box, { "aria-hidden": true, pos: "absolute", left: 0, right: 0, top: "50%", h: 2, bg: "var(--mantine-primary-color-filled)", style: { opacity: shown ? 1 : 0, transition: 'opacity 120ms ease' } }), _jsx(Group, { justify: "center", h: "100%", style: { position: 'relative' }, children: _jsx(TemplateMenu, { templates: templates, onPick: onPick, onOpenChange: setOpened, target: _jsx(Tooltip, { label: "Add section here", openDelay: 300, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "sm", radius: "xl", variant: "default", "aria-label": "Add section here", onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: { opacity, transition: 'opacity 120ms ease' }, children: _jsx(IconPlus, { size: 14, "aria-hidden": true }) }) }) }) }) })] }));
40
+ return (_jsxs(Box, { h: 20, pos: "relative", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), "data-testid": "block-insert-between", children: [_jsx(Box, { "aria-hidden": true, pos: "absolute", left: 0, right: 0, top: "50%", h: 2, bg: "var(--mantine-primary-color-filled)", style: { opacity: shown ? 1 : 0, transition: 'opacity 120ms ease' } }), _jsx(Group, { justify: "center", h: "100%", style: { position: 'relative' }, children: _jsx(TemplateMenu, { templates: templates, onPick: onPick, onOpenChange: setOpened, disabled: !!blockedReason, target: _jsx(Tooltip, { label: blockedReason ?? 'Add section here', openDelay: 300, multiline: !!blockedReason, maw: 280, children: _jsx(Menu.Target, { children: _jsxs(ActionIcon, { size: "sm", radius: "xl", variant: "default", "aria-label": "Add section here", "aria-disabled": blockedReason ? true : undefined, "aria-describedby": blockedReason ? reasonId : undefined, "data-disabled": blockedReason ? true : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: { opacity, transition: 'opacity 120ms ease' }, children: [_jsx(IconPlus, { size: 14, "aria-hidden": true }), blockedReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: blockedReason }))] }) }) }) }) })] }));
39
41
  };
40
- export const BlockField = ({ label, required, description, templates, value, onChange, renderField, path, dataCanopyField, readOnly = false, resetKey = '', hasErrors, onRemoved, }) => {
42
+ export const BlockField = ({ label, required, description, templates, value, onChange, renderField, path, dataCanopyField, readOnly = false, resetKey = '', hasErrors, onRemoved, maxItems, }) => {
41
43
  const descriptionBaseId = useId();
42
44
  const commentControl = useFieldCommentControl(dataCanopyField ?? formatCanopyPath(path));
43
45
  const keysFor = useListItemKeys();
@@ -48,7 +50,12 @@ export const BlockField = ({ label, required, description, templates, value, onC
48
50
  if (!readOnly)
49
51
  onChange(next);
50
52
  };
53
+ const fullReason = maxItems !== undefined && value.length >= maxItems
54
+ ? listFullReason(maxItems, 'section', 'sections')
55
+ : undefined;
51
56
  const insertAt = (index, block) => {
57
+ if (fullReason)
58
+ return;
52
59
  focusAfterRenderRef.current = index;
53
60
  emit([...value.slice(0, index), block, ...value.slice(index)]);
54
61
  };
@@ -81,7 +88,7 @@ export const BlockField = ({ label, required, description, templates, value, onC
81
88
  ...value.slice(0, idx + 1),
82
89
  withFreshItemKeys({ type: 'block', name: 'blocks', templates }, structuredClone(value[idx])),
83
90
  ...value.slice(idx + 1),
84
- ]), onRemove: (idx) => {
91
+ ]), duplicateBlockedReason: fullReason, onRemove: (idx) => {
85
92
  if (readOnly)
86
93
  return;
87
94
  const block = value[idx];
@@ -89,7 +96,7 @@ export const BlockField = ({ label, required, description, templates, value, onC
89
96
  onRemoved?.(idx, block);
90
97
  }, renderBetween: readOnly
91
98
  ? undefined
92
- : (idx) => _jsx(InsertBetween, { templates: templates, onPick: addAt(idx) }), renderItem: (block, idx) => {
99
+ : (idx) => (_jsx(InsertBetween, { templates: templates, onPick: addAt(idx), blockedReason: fullReason })), renderItem: (block, idx) => {
93
100
  const template = findTemplate(templates, block.template);
94
101
  const currentPath = [...path, idx];
95
102
  if (!template) {
@@ -99,5 +106,5 @@ export const BlockField = ({ label, required, description, templates, value, onC
99
106
  ...(isPlainRecord(block.value) ? block.value : {}),
100
107
  [f.name]: next,
101
108
  }), [...currentPath, f.name]) }, f.name)));
102
- } }), value.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'No sections.' : 'No sections yet.' })), !readOnly && (_jsx(Group, { children: _jsx(TemplateMenu, { templates: templates, onPick: addAt(value.length), target: _jsx(Menu.Target, { children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), "data-testid": "block-add-section", children: ADD_SECTION }) }) }) }))] }) }));
109
+ } }), value.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'No sections.' : 'No sections yet.' })), !readOnly && (_jsx(Group, { children: _jsx(TemplateMenu, { templates: templates, onPick: addAt(value.length), disabled: !!fullReason, target: _jsx(Tooltip, { label: fullReason, disabled: !fullReason, multiline: true, maw: 280, children: _jsx(Menu.Target, { children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), "data-disabled": fullReason ? true : undefined, "aria-disabled": fullReason ? true : undefined, "data-testid": "block-add-section", children: ADD_SECTION }) }) }) }) }))] }) }));
103
110
  };
@@ -1,9 +1,17 @@
1
1
  import React from 'react';
2
+ import { type MantineColor } from '@mantine/core';
2
3
  import type { ItemExpansion } from './item-expansion.js';
3
4
  /** What a collapsed row shows: its title, and a second value from its content. */
4
5
  interface ItemSummary {
5
6
  title: string;
6
7
  secondary?: string;
8
+ /** A status shown after the title, in its colour's light variant; part of the row's name. */
9
+ badge?: {
10
+ label: string;
11
+ color: MantineColor;
12
+ };
13
+ /** The title is still loading: the row shows a placeholder in its place. */
14
+ pending?: boolean;
7
15
  }
8
16
  export interface ItemListProps<T> {
9
17
  items: readonly T[];
@@ -20,7 +28,10 @@ export interface ItemListProps<T> {
20
28
  /** True when a field inside row `index` has a validation error. */
21
29
  hasErrors?: (index: number) => boolean;
22
30
  onMove: (from: number, to: number) => void;
23
- onDuplicate: (index: number) => void;
31
+ /** Without it, the row menu offers no Duplicate. */
32
+ onDuplicate?: (index: number) => void;
33
+ /** Why Duplicate can't act now, such as a list at its `maxItems`; shown in its place. */
34
+ duplicateBlockedReason?: string;
24
35
  /** Takes row `index` out; focus has already moved to the enclosing `[data-removal-focus]` list. */
25
36
  onRemove: (index: number) => void;
26
37
  /** Rendered before row `index`, such as a block list's insert-here control. */
@@ -30,13 +41,20 @@ export interface ItemListProps<T> {
30
41
  * Cleared once used.
31
42
  */
32
43
  focusAfterRenderRef?: React.MutableRefObject<number | null>;
44
+ /**
45
+ * Said through the list's live region, by a caller whose own control keeps focus when it adds a
46
+ * row. Pass a new object for each announcement.
47
+ */
48
+ announcement?: {
49
+ message: string;
50
+ };
33
51
  'data-testid'?: string;
34
52
  }
35
53
  /**
36
54
  * The rows of an object list or block list: each collapses to a one-line summary and carries a
37
55
  * drag handle (pointer and keyboard, via dnd-kit) and a "…" menu with Move up, Move down,
38
- * Duplicate and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
56
+ * Duplicate (when the list offers it) and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
39
57
  * and opens when something asks to reveal a field inside it (`REVEAL_FIELD_EVENT`).
40
58
  */
41
- export declare function ItemList<T>({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, 'data-testid': testId, }: ItemListProps<T>): import("react/jsx-runtime").JSX.Element;
59
+ export declare function ItemList<T>({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, announcement: callerAnnouncement, duplicateBlockedReason, 'data-testid': testId, }: ItemListProps<T>): import("react/jsx-runtime").JSX.Element;
42
60
  export {};