@forumone/throughline-design-system 0.0.0 → 1.0.0-next.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 (122) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/bin/check-block-props.mjs +51 -0
  5. package/bin/stub-styles.mjs +28 -0
  6. package/dist/admin/BlockGuidance.d.ts +13 -0
  7. package/dist/admin/BlockGuidance.d.ts.map +1 -0
  8. package/dist/admin/BlockGuidance.js +13 -0
  9. package/dist/admin/BlockGuidance.js.map +1 -0
  10. package/dist/admin/BlockSummary.d.ts +10 -0
  11. package/dist/admin/BlockSummary.d.ts.map +1 -0
  12. package/dist/admin/BlockSummary.js +73 -0
  13. package/dist/admin/BlockSummary.js.map +1 -0
  14. package/dist/admin/RowSummary.d.ts +15 -0
  15. package/dist/admin/RowSummary.d.ts.map +1 -0
  16. package/dist/admin/RowSummary.js +16 -0
  17. package/dist/admin/RowSummary.js.map +1 -0
  18. package/dist/admin/summary.d.ts +18 -0
  19. package/dist/admin/summary.d.ts.map +1 -0
  20. package/dist/admin/summary.js +48 -0
  21. package/dist/admin/summary.js.map +1 -0
  22. package/dist/altText.d.ts +16 -0
  23. package/dist/altText.d.ts.map +1 -0
  24. package/dist/altText.js +54 -0
  25. package/dist/altText.js.map +1 -0
  26. package/dist/client.d.ts +15 -0
  27. package/dist/client.d.ts.map +1 -0
  28. package/dist/client.js +12 -0
  29. package/dist/client.js.map +1 -0
  30. package/dist/contract/index.d.ts +5 -0
  31. package/dist/contract/index.d.ts.map +1 -0
  32. package/dist/contract/index.js +4 -0
  33. package/dist/contract/index.js.map +1 -0
  34. package/dist/contract/lint.d.ts +38 -0
  35. package/dist/contract/lint.d.ts.map +1 -0
  36. package/dist/contract/lint.js +110 -0
  37. package/dist/contract/lint.js.map +1 -0
  38. package/dist/contract/loader.d.ts +49 -0
  39. package/dist/contract/loader.d.ts.map +1 -0
  40. package/dist/contract/loader.js +95 -0
  41. package/dist/contract/loader.js.map +1 -0
  42. package/dist/contract/manifest.d.ts +454 -0
  43. package/dist/contract/manifest.d.ts.map +1 -0
  44. package/dist/contract/manifest.js +29 -0
  45. package/dist/contract/manifest.js.map +1 -0
  46. package/dist/contract/schema.d.ts +334 -0
  47. package/dist/contract/schema.d.ts.map +1 -0
  48. package/dist/contract/schema.js +224 -0
  49. package/dist/contract/schema.js.map +1 -0
  50. package/dist/generate/blocks.d.ts +79 -0
  51. package/dist/generate/blocks.d.ts.map +1 -0
  52. package/dist/generate/blocks.js +118 -0
  53. package/dist/generate/blocks.js.map +1 -0
  54. package/dist/generate/fields.d.ts +60 -0
  55. package/dist/generate/fields.d.ts.map +1 -0
  56. package/dist/generate/fields.js +478 -0
  57. package/dist/generate/fields.js.map +1 -0
  58. package/dist/generate/guidance.d.ts +5 -0
  59. package/dist/generate/guidance.d.ts.map +1 -0
  60. package/dist/generate/guidance.js +29 -0
  61. package/dist/generate/guidance.js.map +1 -0
  62. package/dist/generate/index.d.ts +13 -0
  63. package/dist/generate/index.d.ts.map +1 -0
  64. package/dist/generate/index.js +9 -0
  65. package/dist/generate/index.js.map +1 -0
  66. package/dist/generate/labels.d.ts +42 -0
  67. package/dist/generate/labels.d.ts.map +1 -0
  68. package/dist/generate/labels.js +226 -0
  69. package/dist/generate/labels.js.map +1 -0
  70. package/dist/generate/layout.d.ts +42 -0
  71. package/dist/generate/layout.d.ts.map +1 -0
  72. package/dist/generate/layout.js +252 -0
  73. package/dist/generate/layout.js.map +1 -0
  74. package/dist/generate/selectOptionSnapshot.d.ts +26 -0
  75. package/dist/generate/selectOptionSnapshot.d.ts.map +1 -0
  76. package/dist/generate/selectOptionSnapshot.js +47 -0
  77. package/dist/generate/selectOptionSnapshot.js.map +1 -0
  78. package/dist/generate/selectOptions.d.ts +64 -0
  79. package/dist/generate/selectOptions.d.ts.map +1 -0
  80. package/dist/generate/selectOptions.js +145 -0
  81. package/dist/generate/selectOptions.js.map +1 -0
  82. package/dist/overrides.d.ts +71 -0
  83. package/dist/overrides.d.ts.map +1 -0
  84. package/dist/overrides.js +16 -0
  85. package/dist/overrides.js.map +1 -0
  86. package/dist/render/RenderBlocks.d.ts +67 -0
  87. package/dist/render/RenderBlocks.d.ts.map +1 -0
  88. package/dist/render/RenderBlocks.js +31 -0
  89. package/dist/render/RenderBlocks.js.map +1 -0
  90. package/dist/render/coerce.d.ts +78 -0
  91. package/dist/render/coerce.d.ts.map +1 -0
  92. package/dist/render/coerce.js +243 -0
  93. package/dist/render/coerce.js.map +1 -0
  94. package/dist/render/index.d.ts +5 -0
  95. package/dist/render/index.d.ts.map +1 -0
  96. package/dist/render/index.js +3 -0
  97. package/dist/render/index.js.map +1 -0
  98. package/dist/testing/checkBlockProps.d.ts +66 -0
  99. package/dist/testing/checkBlockProps.d.ts.map +1 -0
  100. package/dist/testing/checkBlockProps.js +241 -0
  101. package/dist/testing/checkBlockProps.js.map +1 -0
  102. package/dist/testing/checkBlockPropsCli.d.ts +12 -0
  103. package/dist/testing/checkBlockPropsCli.d.ts.map +1 -0
  104. package/dist/testing/checkBlockPropsCli.js +86 -0
  105. package/dist/testing/checkBlockPropsCli.js.map +1 -0
  106. package/dist/testing/contractDefaults.d.ts +21 -0
  107. package/dist/testing/contractDefaults.d.ts.map +1 -0
  108. package/dist/testing/contractDefaults.js +65 -0
  109. package/dist/testing/contractDefaults.js.map +1 -0
  110. package/dist/testing/describeBlockInvariants.d.ts +39 -0
  111. package/dist/testing/describeBlockInvariants.d.ts.map +1 -0
  112. package/dist/testing/describeBlockInvariants.js +71 -0
  113. package/dist/testing/describeBlockInvariants.js.map +1 -0
  114. package/dist/testing/index.d.ts +18 -0
  115. package/dist/testing/index.d.ts.map +1 -0
  116. package/dist/testing/index.js +14 -0
  117. package/dist/testing/index.js.map +1 -0
  118. package/dist/testing/untouchedBlocks.d.ts +48 -0
  119. package/dist/testing/untouchedBlocks.d.ts.map +1 -0
  120. package/dist/testing/untouchedBlocks.js +155 -0
  121. package/dist/testing/untouchedBlocks.js.map +1 -0
  122. package/package.json +108 -1
@@ -0,0 +1,118 @@
1
+ import { groupOf } from '../contract/index.js';
2
+ import { withAltFallback } from '../altText.js';
3
+ import { toPayloadField } from './fields.js';
4
+ import { blockGuidance, humanize } from './guidance.js';
5
+ import { groupLabel } from './labels.js';
6
+ import { arrange, blockSummaryFields } from './layout.js';
7
+ const BLOCK_SUMMARY = '@forumone/throughline-design-system/client#BlockSummary';
8
+ /**
9
+ * Which components are authorable as blocks.
10
+ *
11
+ * `inline` placement means the component is a building block used *inside*
12
+ * another one — a `Card` within a `CardGrid`, a `Button` within a CTA. It
13
+ * reaches the page through its parent's fields, so offering it in the palette
14
+ * would let an author drop a bare card onto a page with nothing around it.
15
+ *
16
+ * `page` and `section` are the two that stand on their own. `notABlock` in the
17
+ * overrides removes the handful that are page-level but not authored — the
18
+ * chrome a template renders rather than a block an author places.
19
+ */
20
+ export function isBlockCandidate(component, overrides) {
21
+ if (overrides[component.name]?.notABlock)
22
+ return false;
23
+ return component.composition.placement.some(p => p === 'page' || p === 'section');
24
+ }
25
+ /**
26
+ * The block slug is the manifest component name, byte for byte.
27
+ *
28
+ * Not a stylistic choice. `@forumone/throughline/publishing`'s composition step maps
29
+ * `blockType` straight to `type` with no transformation and looks it up in the
30
+ * manifest; a miss is an `unknown-component` **error**, which blocks publish.
31
+ * Kebab-casing the slugs would make every block on every page unpublishable,
32
+ * and the error would point at the design system rather than at the naming.
33
+ */
34
+ export function generateBlock(component, options) {
35
+ const ctx = {
36
+ component: component.name,
37
+ overrides: options.overrides,
38
+ mediaCollection: options.mediaCollection,
39
+ linkCollections: options.linkCollections,
40
+ resolveSelectOptions: options.resolveSelectOptions,
41
+ resolveNamedOptions: options.resolveNamedOptions,
42
+ ...(options.optionLabels ? { optionLabels: options.optionLabels } : {}),
43
+ };
44
+ // Arranged for an author to read — pairs grouped, settings behind a
45
+ // disclosure. Presentational only; see `./layout.ts`. An alt beside its image
46
+ // falls back to the media's, so it is generated optional; see `../altText.ts`.
47
+ const contract = withAltFallback(component.content.fields);
48
+ const generated = contract.map(field => toPayloadField(field, ctx));
49
+ const fields = arrange(contract, generated, { disclose: true });
50
+ const preview = options.resolvePreview?.(component.name) ?? null;
51
+ const guidance = blockGuidance(component.intent, Object.keys(options.manifest.components));
52
+ // The picker's name for it. An override for the few names a mechanical split
53
+ // gets wrong — `AtAGlance` → "At AGlance" — or that mislead.
54
+ const label = options.overrides[component.name]?.label ?? humanize(component.name);
55
+ return {
56
+ slug: component.name,
57
+ interfaceName: `${component.name}Block`,
58
+ // The preview is described by the block's label, not its slug: a screen
59
+ // reader announced "A preview of the HighImpactCTA component".
60
+ ...(preview
61
+ ? { imageURL: preview.url, imageAltText: preview.alt ?? `A preview of the ${label} block` }
62
+ : {}),
63
+ labels: {
64
+ singular: label,
65
+ plural: label,
66
+ },
67
+ admin: {
68
+ // A collapsed block says what it holds, not only what type it is — see
69
+ // `../admin/BlockSummary.tsx`. Every block gets it, including one with
70
+ // no text to show, so every header on a page is drawn by the same code.
71
+ components: {
72
+ Label: {
73
+ path: BLOCK_SUMMARY,
74
+ clientProps: {
75
+ fields: blockSummaryFields(component.content.fields, generated),
76
+ singular: label,
77
+ slug: component.name,
78
+ },
79
+ },
80
+ },
81
+ // Which shelf of the picker this block sits on.
82
+ //
83
+ // `groupOf` is the contract's resolver: a component's `group` when it
84
+ // sets one, its `category` when it does not. Both fields are read through
85
+ // it rather than either directly, so a component that has not been given
86
+ // a group still lands somewhere sensible instead of on a nameless shelf.
87
+ //
88
+ // The two are different questions. `category` is what the component *is*,
89
+ // and other consumers read it that way — the components MCP server
90
+ // filters on it. `group` is where an author looks for it. Grouping on
91
+ // `category` alone put over half the picker under "Section", which is the
92
+ // flat list the grouping exists to avoid.
93
+ group: groupLabel(groupOf(component)),
94
+ },
95
+ // A block with no authorable fields is still legitimate: some components
96
+ // are entirely presentational. Payload needs a field array, not a non-empty
97
+ // one.
98
+ //
99
+ // Headed by what the block is for — see `./guidance.ts`. A `ui` field, so
100
+ // it stores nothing and is absent from the generated types.
101
+ fields: guidance ? [guidanceField(guidance), ...fields] : fields,
102
+ };
103
+ }
104
+ const BLOCK_GUIDANCE = '@forumone/throughline-design-system/client#BlockGuidance';
105
+ function guidanceField(text) {
106
+ return {
107
+ name: 'blockGuidance',
108
+ type: 'ui',
109
+ admin: { components: { Field: { path: BLOCK_GUIDANCE, clientProps: { text } } } },
110
+ };
111
+ }
112
+ export function generateBlocks(options) {
113
+ return Object.values(options.manifest.components)
114
+ .filter(component => isBlockCandidate(component, options.overrides))
115
+ .sort((a, b) => a.name.localeCompare(b.name))
116
+ .map(component => ({ block: generateBlock(component, options), component }));
117
+ }
118
+ //# sourceMappingURL=blocks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blocks.js","sourceRoot":"","sources":["../../src/generate/blocks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ import type { Field } from 'payload';
2
+ import { type Overrides } from '../overrides.js';
3
+ /**
4
+ * One field of a component's content model, as the manifest describes it.
5
+ * Restated rather than imported from `@forumone/throughline-design-system/contract`
6
+ * because that package exports the Zod-inferred shape, and this only needs the
7
+ * parts the generator reads.
8
+ */
9
+ export interface ContentField {
10
+ name: string;
11
+ type: 'text' | 'richtext' | 'link' | 'image' | 'video' | 'select' | 'group' | 'array' | 'boolean' | 'number';
12
+ required: boolean;
13
+ maxLength?: number;
14
+ /** `boolean` only — see the contract schema. */
15
+ defaultValue?: boolean;
16
+ constraints?: string;
17
+ /**
18
+ * An optional field most authors should leave alone: an override of a
19
+ * default the component already chooses well, or screen-reader and status
20
+ * copy it already supplies. Drawn in a collapsed "More options" section —
21
+ * see `./layout.ts`.
22
+ */
23
+ advanced?: boolean;
24
+ of?: ContentField[];
25
+ }
26
+ export interface FieldContext {
27
+ /** Component name, for override lookup and error messages. */
28
+ component: string;
29
+ overrides: Overrides;
30
+ /** Upload collection an `image` field points at. */
31
+ mediaCollection: string;
32
+ /** Collections an internal link may target. */
33
+ linkCollections: string[];
34
+ /** Resolves `select` options for `<Component>.<path>`, or null if it cannot. */
35
+ resolveSelectOptions: (component: string, path: string) => readonly string[] | null;
36
+ /** Resolves a named exported union — used for the global icon set. */
37
+ resolveNamedOptions: (typeName: string) => readonly string[] | null;
38
+ /** The host's words for option values particular to its design system — see `optionLabel`. */
39
+ optionLabels?: Readonly<Record<string, string>>;
40
+ }
41
+ /**
42
+ * A link, as one group rather than 41 ad-hoc ones.
43
+ *
44
+ * Internal links hold a relationship, not a path, so renaming a page cannot
45
+ * break every link that pointed at it — the renderer resolves the current slug
46
+ * at read time.
47
+ *
48
+ * `required` is enforced by a validate rather than by Payload's own flag, for
49
+ * the reason `requiredLink` gives.
50
+ */
51
+ export declare function linkField(name: string, ctx: FieldContext, description?: string, required?: boolean): Field;
52
+ /**
53
+ * Turns one contract field into one Payload field.
54
+ *
55
+ * Returns null when the field is deliberately not authorable — an omitted
56
+ * runtime-state field, or a slot, which is a component position rather than
57
+ * content and is filled by the template.
58
+ */
59
+ export declare function toPayloadField(field: ContentField, ctx: FieldContext, path?: string): Field | null;
60
+ //# sourceMappingURL=fields.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../../src/generate/fields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,KAAK,EAAE,MAAM,SAAS,CAAA;AAGpD,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAI/D;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EACA,MAAM,GACN,UAAU,GACV,MAAM,GACN,OAAO,GACP,OAAO,GACP,QAAQ,GACR,OAAO,GACP,OAAO,GACP,SAAS,GACT,QAAQ,CAAA;IACZ,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gDAAgD;IAChD,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,YAAY,EAAE,CAAA;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,SAAS,CAAA;IACpB,oDAAoD;IACpD,eAAe,EAAE,MAAM,CAAA;IACvB,+CAA+C;IAC/C,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,gFAAgF;IAChF,oBAAoB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,IAAI,CAAA;IACnF,sEAAsE;IACtE,mBAAmB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,IAAI,CAAA;IACnE,8FAA8F;IAC9F,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAChD;AAsCD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,YAAY,EACjB,WAAW,CAAC,EAAE,MAAM,EACpB,QAAQ,UAAQ,GACf,KAAK,CA0DP;AAED;;;;;;GAMG;AAwCH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,YAAY,EACjB,IAAI,GAAE,MAAmB,GACxB,KAAK,GAAG,IAAI,CAKd"}
@@ -0,0 +1,478 @@
1
+ import { formatLabels } from 'payload/shared';
2
+ import { withAltFallback } from '../altText.js';
3
+ import { fieldOverride } from '../overrides.js';
4
+ import { labelFor, optionLabel, selectDescription } from './labels.js';
5
+ import { arrange, oneLine, summaryFields } from './layout.js';
6
+ /** The design system's exported union of every glyph name. */
7
+ const ICON_NAME_TYPE = 'IconName';
8
+ /** The import-map path of the header a generated array row draws. */
9
+ const ROW_SUMMARY = '@forumone/throughline-design-system/client#RowSummary';
10
+ /** Above this, a single-line input stops being the right control. */
11
+ const TEXTAREA_THRESHOLD = 160;
12
+ function describe(field) {
13
+ return field.constraints;
14
+ }
15
+ /**
16
+ * A select, with every option labelled and its description rid of the
17
+ * sentences that only listed the options — see `optionLabel` and
18
+ * `selectDescription` in `./labels.ts`. Every generated select comes through
19
+ * here, whichever branch resolved its values.
20
+ */
21
+ function selectField(field, values, ctx, required) {
22
+ const label = (value) => optionLabel(value, ctx.optionLabels);
23
+ const description = selectDescription(describe(field), values, label);
24
+ return {
25
+ name: field.name,
26
+ type: 'select',
27
+ options: values.map(value => ({ label: label(value), value })),
28
+ ...required,
29
+ ...(description ? { admin: { description } } : {}),
30
+ };
31
+ }
32
+ /**
33
+ * A link, as one group rather than 41 ad-hoc ones.
34
+ *
35
+ * Internal links hold a relationship, not a path, so renaming a page cannot
36
+ * break every link that pointed at it — the renderer resolves the current slug
37
+ * at read time.
38
+ *
39
+ * `required` is enforced by a validate rather than by Payload's own flag, for
40
+ * the reason `requiredLink` gives.
41
+ */
42
+ export function linkField(name, ctx, description, required = false) {
43
+ return {
44
+ name,
45
+ type: 'group',
46
+ ...(description ? { admin: { description } } : {}),
47
+ ...(required ? { validate: requiredLink() } : {}),
48
+ fields: [
49
+ {
50
+ name: 'mode',
51
+ type: 'radio',
52
+ label: 'Links to',
53
+ defaultValue: 'internal',
54
+ options: [
55
+ { label: 'A page on this site', value: 'internal' },
56
+ { label: 'Another site', value: 'external' },
57
+ { label: 'Somewhere on this page', value: 'anchor' },
58
+ ],
59
+ admin: { layout: 'horizontal' },
60
+ },
61
+ {
62
+ name: 'reference',
63
+ type: 'relationship',
64
+ label: 'Page',
65
+ // The package is generic over any Payload config, so it cannot know
66
+ // this project's slugs; `CollectionSlug` is generated per app.
67
+ relationTo: ctx.linkCollections,
68
+ admin: { condition: (_d, sibling) => sibling?.mode === 'internal' },
69
+ },
70
+ {
71
+ name: 'url',
72
+ type: 'text',
73
+ label: 'URL',
74
+ admin: {
75
+ condition: (_d, sibling) => sibling?.mode === 'external',
76
+ description: 'Include the protocol — https://…',
77
+ },
78
+ },
79
+ {
80
+ name: 'anchor',
81
+ type: 'text',
82
+ label: 'Anchor',
83
+ admin: {
84
+ condition: (_d, sibling) => sibling?.mode === 'anchor',
85
+ description: 'The id of the element to scroll to, without the #.',
86
+ },
87
+ },
88
+ {
89
+ name: 'newTab',
90
+ type: 'checkbox',
91
+ label: 'Open in a new tab',
92
+ admin: {
93
+ condition: (_d, sibling) => sibling?.mode === 'external',
94
+ description: 'Opening a new tab takes the back button away, so reserve it for leaving the site.',
95
+ },
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ /**
101
+ * Turns one contract field into one Payload field.
102
+ *
103
+ * Returns null when the field is deliberately not authorable — an omitted
104
+ * runtime-state field, or a slot, which is a component position rather than
105
+ * content and is filled by the template.
106
+ */
107
+ /*
108
+ An upload field cannot be stopped from editing the document it points at.
109
+
110
+ Worth writing down, because the fix looks obvious, was tried, shipped inert in
111
+ 0.4.3, and would be tried again.
112
+
113
+ Payload's upload field offers two actions side by side and they look alike. The
114
+ picker changes which library document this block points at: local to the block,
115
+ carried by the draft, published when the page is. The edit pencil opens that
116
+ document in a drawer whose file can be removed and replaced: global, immediate,
117
+ and shared by every block that picked it. Downstream that turned "change the
118
+ photograph on this page" into "change it on all five pages that picked this
119
+ photograph, now, while the page you are looking at is still a draft".
120
+
121
+ `admin: { allowEdit: false }` does not prevent it, for three independent
122
+ reasons, in payload 3.87.1:
123
+
124
+ - it is not an option. `UploadAdmin` is `allowCreate` and `isSortable`;
125
+ `allowEdit` belongs to `RelationshipAdmin`, a different field type
126
+ - it would not survive the trip. `UploadAdminClient` is
127
+ `AdminClient & Pick<UploadAdmin, 'allowCreate' | 'isSortable'>`
128
+ - and the component does not read it. `@payloadcms/ui`'s
129
+ `fields/Upload/HasOne` renders `allowEdit: !readonly`, hardcoded. Compare
130
+ `allowCreate`, which *is* wired through `fields/Upload/index.js`
131
+
132
+ So the only way to lose the pencil is `readOnly`, which takes the picker with
133
+ it. The asymmetry with `allowCreate` looks like an upstream oversight rather
134
+ than a decision, and is filed as such — until it moves, this is a Payload
135
+ behaviour a host has to design around rather than a generator setting.
136
+
137
+ **How the inert version passed every gate**, which is the part worth keeping.
138
+ It was written through a helper returning `{ admin: Record<string, unknown> }`,
139
+ and that widening is exactly what suppresses the excess-property check: written
140
+ inline, `tsc` rejects `allowEdit` on an upload's admin, and does so today. The
141
+ tests then asserted `allowEdit` was present *on the generated config object* —
142
+ one end of a string whose other end nothing reads. That is the same shape as a
143
+ cache tag nobody listens to, and it is why the assertions in this file are
144
+ about behaviour wherever behaviour is reachable.
145
+ */
146
+ export function toPayloadField(field, ctx, path = field.name) {
147
+ const built = buildField(field, ctx, path);
148
+ // Every generated field is labelled explicitly — see `./labels.ts` for why
149
+ // Payload's own title-cased fallback is not good enough.
150
+ return built ? { ...built, label: labelFor(field) } : null;
151
+ }
152
+ function buildField(field, ctx, path) {
153
+ const override = fieldOverride(ctx.overrides, ctx.component, path);
154
+ if (override?.omit)
155
+ return null;
156
+ const description = describe(field);
157
+ const admin = description ? { admin: { description } } : {};
158
+ const required = field.required ? { required: true } : {};
159
+ if (override?.as === 'icon') {
160
+ const options = override.options ??
161
+ ctx.resolveSelectOptions(ctx.component, path) ??
162
+ ctx.resolveNamedOptions(ICON_NAME_TYPE);
163
+ if (!options) {
164
+ throw new Error(`${ctx.component}.${path} is marked as an icon field but no options could be resolved, ` +
165
+ `and neither could the design system's ${ICON_NAME_TYPE}.`);
166
+ }
167
+ return selectField(field, options, ctx, required);
168
+ }
169
+ switch (field.type) {
170
+ case 'text': {
171
+ /*
172
+ A text field whose prop is a union of string literals is a select that
173
+ the contract could not say was one — `ContentField` has ten types and no
174
+ way to mean "one of these names". The component's own type does say it,
175
+ so read it: `IntroSection.actions[].icon` is `keyof typeof Icons`, and a
176
+ free-text box there would let an author type a glyph name that renders
177
+ nothing at all.
178
+ */
179
+ const asUnion = ctx.resolveSelectOptions(ctx.component, path);
180
+ if (asUnion) {
181
+ return selectField(field, asUnion, ctx, required);
182
+ }
183
+ // A 600-character answer in a single-line input is a usability bug, not
184
+ // a styling preference. `text` and `textarea` are separate field types in
185
+ // Payload, so the branch has to produce whole objects rather than a
186
+ // computed `type`.
187
+ const long = field.maxLength === undefined || field.maxLength > TEXTAREA_THRESHOLD;
188
+ const length = field.maxLength === undefined ? {} : { maxLength: field.maxLength };
189
+ return long
190
+ ? { name: field.name, type: 'textarea', ...length, ...required, ...admin }
191
+ : { name: field.name, type: 'text', ...length, ...required, ...admin };
192
+ }
193
+ case 'number':
194
+ return { name: field.name, type: 'number', ...required, ...admin };
195
+ case 'boolean':
196
+ // `required` on a checkbox would mean "must be ticked", which is never
197
+ // what a contract means by it.
198
+ //
199
+ // `defaultValue` was hardcoded `false` here, which made a component's own
200
+ // default unreachable: `coerce` turns whatever is stored into a real
201
+ // boolean, so the prop is never `undefined` and a signature default like
202
+ // `hasFacade = true` can never apply. A contract that wants the box
203
+ // ticked to begin with now says so and is believed.
204
+ return {
205
+ name: field.name,
206
+ type: 'checkbox',
207
+ defaultValue: field.defaultValue ?? false,
208
+ ...admin,
209
+ };
210
+ case 'richtext':
211
+ return { name: field.name, type: 'richText', ...required, ...admin };
212
+ case 'image':
213
+ return {
214
+ name: field.name,
215
+ type: 'upload',
216
+ relationTo: ctx.mediaCollection,
217
+ ...required,
218
+ ...admin,
219
+ };
220
+ case 'video':
221
+ /*
222
+ Two kinds of video wear the same contract type, and the override is what
223
+ tells them apart.
224
+
225
+ `videoUpload` is a file the site serves itself — `VideoHero`'s background
226
+ clip — so it is an upload against the same media collection an image
227
+ uses, which already accepts `video/mp4`. Everything else is a provider
228
+ embed URL: `VideoEmbed` takes a YouTube/Vimeo/Wistia src and has a
229
+ separate `poster` field that *is* an image.
230
+ */
231
+ if (override?.as === 'videoUpload') {
232
+ return {
233
+ name: field.name,
234
+ type: 'upload',
235
+ relationTo: ctx.mediaCollection,
236
+ ...required,
237
+ ...admin,
238
+ };
239
+ }
240
+ return {
241
+ name: field.name,
242
+ type: 'text',
243
+ ...required,
244
+ admin: {
245
+ description: description ?? 'A YouTube or Vimeo URL.',
246
+ },
247
+ };
248
+ case 'link':
249
+ return linkField(field.name, ctx, description, field.required);
250
+ case 'select': {
251
+ const options = override?.options ?? ctx.resolveSelectOptions(ctx.component, path);
252
+ if (!options) {
253
+ throw new Error(`${ctx.component}.${path} is a select, but its allowed values could not be resolved. ` +
254
+ `The manifest does not carry them — they live in the component's own literal union ` +
255
+ `type, or in an \`options\` override when it has none.`);
256
+ }
257
+ return selectField(field, options, ctx, required);
258
+ }
259
+ case 'group': {
260
+ // A group with no children is a *slot* — a place a component renders
261
+ // another component, declared in `composition.allowedSlots`. It holds no
262
+ // authored content, so there is nothing to put in the CMS.
263
+ if (!field.of || field.of.length === 0)
264
+ return null;
265
+ const of = withAltFallback(field.of);
266
+ const children = childFields(of, ctx, path);
267
+ /*
268
+ An optional group holds its children to a weaker promise than it looks.
269
+
270
+ `ProseSection.image` is `required: false` with `src` and `alt` both
271
+ required inside it — meaning "an image is optional, but a *half* image is
272
+ not". Generated literally, Payload validates those children whether or
273
+ not the group has anything in it, so every block carrying an optional
274
+ image became a block that could not be published without one. Six of the
275
+ eight blocks on the About page failed that way.
276
+
277
+ So inside an optional group the children are relaxed, and the promise the
278
+ contract actually made is enforced on the group instead: fill it or leave
279
+ it, but do not half-fill it.
280
+ */
281
+ if (field.required) {
282
+ return { name: field.name, type: 'group', ...admin, fields: children };
283
+ }
284
+ return {
285
+ name: field.name,
286
+ type: 'group',
287
+ ...admin,
288
+ validate: allOrNothing(of),
289
+ fields: relaxRequired(children),
290
+ };
291
+ }
292
+ case 'array': {
293
+ if (!field.of || field.of.length === 0) {
294
+ throw new Error(`${ctx.component}.${path} is an array with no \`of\` describing its rows.`);
295
+ }
296
+ // A row is drawn on one line and named by its contents where it can be
297
+ // — see the end of `./layout.ts`.
298
+ const of = withAltFallback(field.of);
299
+ const generated = generateChildren(of, ctx, path);
300
+ const summary = summaryFields(of, generated);
301
+ const rowLabel = summary.length > 0
302
+ ? {
303
+ components: {
304
+ RowLabel: {
305
+ path: ROW_SUMMARY,
306
+ clientProps: { fields: summary, singular: formatLabels(field.name).singular },
307
+ },
308
+ },
309
+ }
310
+ : {};
311
+ return {
312
+ name: field.name,
313
+ type: 'array',
314
+ // Payload has no "required array"; a minimum of one row is what the
315
+ // contract means.
316
+ ...(field.required ? { minRows: 1 } : {}),
317
+ ...(description || summary.length > 0
318
+ ? { admin: { ...(description ? { description } : {}), ...rowLabel } }
319
+ : {}),
320
+ fields: oneLine(childFields(of, ctx, path, generated)),
321
+ };
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * Strip `required` from a group's children, recursing into nested groups.
327
+ *
328
+ * Only ever applied inside an optional group — see the `group` case. An array
329
+ * keeps its `minRows`, because Payload does not enforce rows on an array whose
330
+ * parent group is empty.
331
+ */
332
+ function relaxRequired(fields) {
333
+ return fields.map(field => {
334
+ const next = 'required' in field && field.required ? { ...field, required: false } : field;
335
+ return 'fields' in next && Array.isArray(next.fields) && next.type === 'group'
336
+ ? { ...next, fields: relaxRequired(next.fields) }
337
+ : next;
338
+ });
339
+ }
340
+ /**
341
+ * "Fill it or leave it, but do not half-fill it."
342
+ *
343
+ * The validation an optional group needs and Payload has no field-level way to
344
+ * express: silent while the group is empty, and demanding of exactly the
345
+ * children the contract marked required once any of them is filled in.
346
+ */
347
+ function allOrNothing(children) {
348
+ const names = children.filter(child => child.required).map(child => child.name);
349
+ /*
350
+ Booleans this group defaults to ticked.
351
+
352
+ `isEmpty` answers for a value alone and reads `false` as "nobody touched
353
+ this", which is right for a checkbox that starts unticked. A checkbox the
354
+ contract starts *ticked* says the same thing with the opposite value, and
355
+ `isEmpty` cannot know that without being told which field it is looking at.
356
+ Left unhandled, such a field makes its group permanently non-empty and
357
+ `allOrNothing` demands the group's required children of an author who has
358
+ typed nothing — the third route to the defect this file's `isEmpty` comment
359
+ describes.
360
+ */
361
+ const defaultTicked = new Set(children.filter(child => child.type === 'boolean' && child.defaultValue === true).map(child => child.name));
362
+ const untouched = (name, v) => defaultTicked.has(name) ? v === true || isEmpty(v) : isEmpty(v);
363
+ return (value) => {
364
+ if (!value || typeof value !== 'object')
365
+ return true;
366
+ const entries = Object.entries(value);
367
+ if (entries.every(([k, v]) => untouched(k, v)))
368
+ return true;
369
+ const missing = names.filter(name => isEmpty(value[name]));
370
+ if (missing.length === 0)
371
+ return true;
372
+ return missing.length === 1
373
+ ? `${missing[0]} is needed once anything else here is filled in. Clear the rest to leave this out entirely.`
374
+ : `${missing.join(' and ')} are needed once anything else here is filled in. Clear the rest to leave this out entirely.`;
375
+ };
376
+ }
377
+ /**
378
+ * A required link must actually go somewhere.
379
+ *
380
+ * `required: true` on a contract link field bought nothing for as long as this
381
+ * generator existed: the flag was read for every other field type and dropped
382
+ * for this one, and Payload's own `required` could not have replaced it anyway
383
+ * — on a group it asserts only that the object exists, which it always does,
384
+ * and on the children it would demand a `url` of a link whose author chose the
385
+ * internal mode. So the promise is kept here, against the branch the author
386
+ * actually picked.
387
+ *
388
+ * It cost a live defect. `FeaturedWork` marks each item's `href` required and
389
+ * types it `string`; two items on `/work` were saved with the link untouched,
390
+ * `resolveHref` returned `undefined`, React dropped the attribute, and the page
391
+ * shipped four `<a>` elements with no `href` — not links at all: skipped by
392
+ * crawlers, unreachable by keyboard, and scoring 0 on Lighthouse's
393
+ * `crawlable-anchors`. Nothing in the CMS had objected. See #483.
394
+ *
395
+ * **Silent until the surroundings are filled in.** A block or an array row
396
+ * Payload has only just created holds defaults, and refusing to save one before
397
+ * anybody has typed into it is the #354 defect — an error on a block the editor
398
+ * cannot yet act on. So the rule reads its siblings: everything around it empty
399
+ * means nobody has been here, and the link is left alone. That is the same
400
+ * bargain `allOrNothing` strikes one level up, and it lands on the case that
401
+ * matters — a row carrying a title and a paragraph and no destination.
402
+ */
403
+ function requiredLink() {
404
+ return (value, options) => {
405
+ const link = (value ?? {});
406
+ const mode = link.mode ?? 'internal';
407
+ if (mode === 'internal' && !isEmpty(link.reference))
408
+ return true;
409
+ if (mode === 'external' && !isEmpty(link.url))
410
+ return true;
411
+ if (mode === 'anchor' && !isEmpty(link.anchor))
412
+ return true;
413
+ if (isEmpty(options?.siblingData))
414
+ return true;
415
+ if (mode === 'external')
416
+ return 'Enter the URL this link goes to.';
417
+ if (mode === 'anchor')
418
+ return 'Name the id this link scrolls to.';
419
+ return 'Choose the page this link goes to.';
420
+ };
421
+ }
422
+ /*
423
+ "Nobody has been here yet", and why it has to be recursive — which is not
424
+ obvious until it bites.
425
+
426
+ A group holding a link sub-group is never literally empty: `linkField` gives
427
+ `mode` a default of `'internal'`, so an untouched `caseStudy` arrives as
428
+ `{ stat: {}, href: { mode: 'internal' } }`. Compared shallowly that is a filled
429
+ group, and `allOrNothing` then demands the title of an author who has typed
430
+ nothing — that validation firing on exactly the case it exists to allow.
431
+
432
+ `mode` is skipped for that reason: it is a discriminator that says which *kind*
433
+ of link this would be if there were one, and it is present whether or not
434
+ anybody chose anything. It is never evidence that a group was filled in.
435
+
436
+ **An unticked checkbox is the same thing, and is why `false` counts as empty.**
437
+ `boolean` fields are generated with `defaultValue: false` unless the contract
438
+ declares otherwise, so a group holding one is never literally empty either — and `false` is indistinguishable from
439
+ "nobody touched this", because Payload stores the default and an author's
440
+ deliberate untick identically. Treating it as filled makes the rule demand the
441
+ group's required children of somebody who has typed nothing, which is the same
442
+ defect this comment already describes, arriving by a second route.
443
+
444
+ It did arrive: `ManagedForm.consent` holds a `required` boolean beside a
445
+ required `text`, and adding the block failed to save with "Consent is invalid"
446
+ before an editor could touch it.
447
+
448
+ A checkbox the contract starts *ticked* says "nobody touched this" with `true`
449
+ instead, which this function cannot see — it is handed a value, not a field.
450
+ `allOrNothing` knows the field shapes and handles that case itself.
451
+
452
+ `requiredLink` asks the same question of a link's *siblings*, which is what
453
+ keeps it quiet on a block or a row nobody has typed into yet.
454
+ */
455
+ function isEmpty(value) {
456
+ if (value === undefined || value === null || value === '')
457
+ return true;
458
+ if (value === false)
459
+ return true;
460
+ if (Array.isArray(value))
461
+ return value.every(isEmpty);
462
+ if (typeof value === 'object') {
463
+ return Object.entries(value).every(([key, held]) => key === 'mode' || isEmpty(held));
464
+ }
465
+ return false;
466
+ }
467
+ function generateChildren(children, ctx, parentPath) {
468
+ return children.map(child => toPayloadField(child, ctx, `${parentPath}.${child.name}`));
469
+ }
470
+ function childFields(children, ctx, parentPath, generated = generateChildren(children, ctx, parentPath)) {
471
+ const fields = arrange(children, generated, { disclose: false });
472
+ if (fields.length === 0) {
473
+ throw new Error(`${ctx.component}.${parentPath} has no authorable children left after overrides. ` +
474
+ `Payload rejects a group or array with an empty \`fields\`.`);
475
+ }
476
+ return fields;
477
+ }
478
+ //# sourceMappingURL=fields.js.map