@byline/core 4.2.0 → 4.4.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.
@@ -270,7 +270,12 @@ export interface CollectionAdminConfig<T = any> {
270
270
  */
271
271
  layout?: LayoutDefinition;
272
272
  /**
273
- * Per-field rendering overrides, keyed by field name.
273
+ * Per-field rendering overrides, keyed by schema path: a top-level field
274
+ * name (`title`) or a dotted, index-free path through `group` / `array`
275
+ * structure fields (`files.filesGroup.publicationFile`). Paths address
276
+ * field *declarations*, never item instances (no `[0]` indices), and never
277
+ * traverse a `blocks` field — blocks take their overrides from the
278
+ * blockType-keyed `ClientConfig.blockAdmin` registry instead.
274
279
  * Placement is no longer expressed here — see the layout primitives above.
275
280
  */
276
281
  fields?: Record<string, FieldAdminConfig>;
@@ -405,25 +410,31 @@ export declare function defineAdmin<T = any>(schema: CollectionDefinition, confi
405
410
  * collection genuinely needs different presentation for "the same" block,
406
411
  * define a second block with a distinct `blockType`.
407
412
  *
408
- * v1 scope: `fields` keys address the block's **top-level** field names only —
409
- * the same limitation `CollectionAdminConfig.fields` has for collections.
410
- * Fields nested inside a group/array within the block inherit site-wide
411
- * defaults.
413
+ * `fields` keys are **schema paths** relative to the block root: a top-level
414
+ * field name (`quoteText`) or a dotted path through `group` / `array`
415
+ * structure fields (`faq.answer`). Schema paths are index-free they address
416
+ * field *declarations*, not item instances (`faq.answer`, never
417
+ * `faq[0].answer`) — the same notation `upload` validation and the upload
418
+ * executor use. Paths never traverse a nested `blocks` field: an inner block
419
+ * resolves its own `blockAdmin` registry entry wherever it renders.
412
420
  */
413
421
  export interface BlockAdminConfig {
414
422
  /** Must match the `blockType` of the corresponding `defineBlock()` definition. */
415
423
  blockType: string;
416
424
  /**
417
425
  * Per-field rendering overrides (`components` slots, richtext `editor`),
418
- * keyed by the block's top-level field names. Same shape and semantics as
426
+ * keyed by index-free schema paths relative to the block root (`alt`,
427
+ * `faq.answer`). Same shape and semantics as
419
428
  * `CollectionAdminConfig.fields`.
420
429
  */
421
430
  fields?: Record<string, FieldAdminConfig>;
422
431
  }
423
432
  /**
424
433
  * Type-safe factory for creating a `BlockAdminConfig` linked to a block
425
- * schema. Sets `blockType` from the block's `blockType` and constrains the
426
- * `fields` keys to the block's own top-level field names.
434
+ * schema. Sets `blockType` from the block's `blockType`. `fields` keys
435
+ * autocomplete the block's top-level field names; nested declarations are
436
+ * addressed with dotted, index-free schema paths (`faq.answer`), validated
437
+ * against the block's field tree at boot (`validateBlockAdminConfigs`).
427
438
  *
428
439
  * Lives on the admin side of the schema/admin split: block schema files stay
429
440
  * React-free and tsx-loadable, while this config may carry React component
@@ -444,5 +455,11 @@ export interface BlockAdminConfig {
444
455
  * ```
445
456
  */
446
457
  export declare function defineBlockAdmin<B extends Block>(block: B, config: {
447
- fields?: Partial<Record<Extract<keyof BlockFieldData<B>, string>, FieldAdminConfig>>;
458
+ /**
459
+ * Keyed by schema path relative to the block root. Top-level field names
460
+ * autocomplete; nested fields are addressed with dotted, index-free
461
+ * paths (`faq.answer`) — accepted as plain strings here and resolved
462
+ * against the block's field tree at boot.
463
+ */
464
+ fields?: Partial<Record<Extract<keyof BlockFieldData<B>, string> | (string & {}), FieldAdminConfig>>;
448
465
  }): BlockAdminConfig;
@@ -17,8 +17,10 @@ export function defineAdmin(schema, config) {
17
17
  }
18
18
  /**
19
19
  * Type-safe factory for creating a `BlockAdminConfig` linked to a block
20
- * schema. Sets `blockType` from the block's `blockType` and constrains the
21
- * `fields` keys to the block's own top-level field names.
20
+ * schema. Sets `blockType` from the block's `blockType`. `fields` keys
21
+ * autocomplete the block's top-level field names; nested declarations are
22
+ * addressed with dotted, index-free schema paths (`faq.answer`), validated
23
+ * against the block's field tree at boot (`validateBlockAdminConfigs`).
22
24
  *
23
25
  * Lives on the admin side of the schema/admin split: block schema files stay
24
26
  * React-free and tsx-loadable, while this config may carry React component
@@ -1152,9 +1152,15 @@ export interface CollectionDefinition {
1152
1152
  *
1153
1153
  * Receives the same minimal document shape as
1154
1154
  * `CollectionAdminConfig.preview.url` (`PreviewDocument`-style envelope:
1155
- * top-level columns plus `fields`) so the two hooks can share an
1156
- * implementation today and `preview.url` can default to
1157
- * `buildDocumentPath` in a later pass.
1155
+ * top-level columns plus `fields`), so the two hooks can share an
1156
+ * implementation. `preview.url` already defaults to this hook when
1157
+ * omitted — see `resolvePreviewUrl` in `@byline/host-tanstack-start`
1158
+ * so the admin Preview button and the public path agree by construction.
1159
+ *
1160
+ * Note the `null` return is read differently by the two surfaces:
1161
+ * `resolvePreviewUrl` hides the Preview affordance, while the richtext
1162
+ * walker treats it as "no path could be built" and falls through to the
1163
+ * generic `/${collectionPath}/${path}` compose.
1158
1164
  */
1159
1165
  buildDocumentPath?: (doc: {
1160
1166
  id: string;
@@ -1,3 +1,4 @@
1
+ import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
1
2
  const registryCollectionHooks = new WeakMap();
2
3
  const registryUploadHooks = new WeakMap();
3
4
  /**
@@ -97,31 +98,25 @@ function assertOwnershipIntact(label, current, owned) {
97
98
  }
98
99
  function indexUploadFields(collection, uploadFields) {
99
100
  const leafPaths = new Map();
100
- const walk = (fields, prefix) => {
101
- for (const field of fields) {
102
- assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
103
- const path = [...prefix, field.name];
104
- if ((field.type === 'file' || field.type === 'image') && field.upload !== undefined) {
105
- const canonical = [collection.path, ...path].join('.');
106
- const previous = leafPaths.get(field.name);
107
- if (previous !== undefined) {
108
- throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
109
- }
110
- leafPaths.set(field.name, canonical);
111
- uploadFields.set(canonical, field);
112
- }
113
- if (field.type === 'group' || field.type === 'array') {
114
- walk(field.fields, path);
115
- }
116
- else if (field.type === 'blocks') {
117
- for (const block of field.blocks) {
118
- assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
119
- walk(block.fields, [...path, block.blockType]);
120
- }
121
- }
101
+ walkFieldDeclarations(collection.fields, (field, segments) => {
102
+ assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
103
+ if ((field.type !== 'file' && field.type !== 'image') || field.upload === undefined)
104
+ return;
105
+ const canonical = `${collection.path}.${formatDeclarationPath(segments)}`;
106
+ const previous = leafPaths.get(field.name);
107
+ if (previous !== undefined) {
108
+ throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
122
109
  }
123
- };
124
- walk(collection.fields, []);
110
+ leafPaths.set(field.name, canonical);
111
+ uploadFields.set(canonical, field);
112
+ }, {
113
+ // Every block is checked, including one declaring no fields — such a
114
+ // block never reaches the field visitor, so inferring block types from
115
+ // field segments alone would silently stop validating them.
116
+ onBlock: (block) => {
117
+ assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
118
+ },
119
+ });
125
120
  }
126
121
  function assertDotFreeSegment(segment, label) {
127
122
  if (segment.length === 0 || segment.includes('.')) {
@@ -24,13 +24,7 @@ describe('resolveRoutes', () => {
24
24
  signIn: '/sign-in',
25
25
  });
26
26
  });
27
- it.each([
28
- '/../cms',
29
- '/cms?next=x',
30
- '/cms#section',
31
- '/cms\\users',
32
- '/%63ms',
33
- ])('rejects a non-segment admin route of %j', (admin) => {
27
+ it.each(['/../cms', '/cms?next=x', '/cms#section', '/cms\\users', '/%63ms'])('rejects a non-segment admin route of %j', (admin) => {
34
28
  expect(() => resolveRoutes({ admin })).toThrow(/routes\.admin/);
35
29
  });
36
30
  it('normalizes and validates the API route too', () => {
@@ -22,8 +22,9 @@ import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition } fr
22
22
  * are unique within an admin config and don't shadow schema field names.
23
23
  * 5. Nesting — `tabSets` only appear in `layout.main`; rows contain only
24
24
  * schema field names; groups exclude tabSets and nested groups.
25
- * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
26
- * schema field name.
25
+ * 6. `fields` map sanity — every key in `admin.fields` is a dotted,
26
+ * index-free schema path resolving to a field declaration (top-level
27
+ * name or a path through group/array fields; never through blocks).
27
28
  * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
28
29
  * or a document-level column (`createdAt` / `updatedAt` / `path`), the
29
30
  * direction (when given) is `asc` | `desc`, and the option is rejected
@@ -43,9 +44,11 @@ export declare function validateAdminConfigs(admins: readonly CollectionAdminCon
43
44
  * `type: 'blocks'` field across the registered collections (blocks have
44
45
  * no global registry; the collections walk is the source of truth).
45
46
  * 2. Uniqueness — no two entries share a `blockType`.
46
- * 3. `fields` map sanity — every key matches a top-level field name of the
47
- * block (when the same `blockType` appears in several collections, the
48
- * union of their top-level field names is accepted).
47
+ * 3. `fields` map sanity — every key is a dotted, index-free schema path
48
+ * resolving to a field declaration of the block (top-level name or a
49
+ * path through group/array fields; never through a nested blocks field).
50
+ * When the same `blockType` appears in several collections, a key is
51
+ * accepted if it resolves in any declaration site (union semantics).
49
52
  *
50
53
  * Throws a plain `Error` for the same reason `validateAdminConfigs` does —
51
54
  * this runs at startup, before the logger is necessarily wired up.
@@ -5,6 +5,39 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { resolveDeclarationPath, walkFieldDeclarations, } from '../paths/index.js';
9
+ /**
10
+ * Resolve a `fields{}` override key against a field set.
11
+ *
12
+ * Admin override keys are declaration paths with one deliberate narrowing:
13
+ * they may not traverse a `blocks` field at all, even correctly qualified by
14
+ * block type. Fields inside a block take their overrides from the
15
+ * blockType-keyed `blockAdmin` registry, so that one registration applies
16
+ * wherever the block renders.
17
+ */
18
+ function resolveSchemaPath(fields, path) {
19
+ return resolveDeclarationPath(fields, path, { blocks: 'forbidden' }).status;
20
+ }
21
+ /**
22
+ * Shared validation for a `fields{}` override map (collection- or block-level):
23
+ * keys must be dotted, index-free schema paths that resolve to a field
24
+ * declaration without traversing a `blocks` field. `subject` names the config
25
+ * in error messages (`Collection "docs"` / `Block "faqBlock"`).
26
+ */
27
+ function validateFieldAdminKeys(keys, resolve, fail) {
28
+ for (const key of keys) {
29
+ if (key.includes('[')) {
30
+ fail(`\`fields["${key}"]\` contains an item index. Field override keys are index-free schema paths addressing field declarations (e.g. "faq.answer"), not instance paths (e.g. "faq[0].answer").`);
31
+ }
32
+ const resolution = resolve(key);
33
+ if (resolution === 'blocks') {
34
+ fail(`\`fields["${key}"]\` traverses a \`type: 'blocks'\` field. Blocks resolve their own overrides from the blockType-keyed \`blockAdmin\` registry — register a block admin config for the inner block instead.`);
35
+ }
36
+ if (resolution === 'unresolved') {
37
+ fail(`\`fields["${key}"]\` does not resolve to a field declaration. Keys are dotted, index-free schema paths whose intermediate segments are \`group\` / \`array\` fields (e.g. "faq.answer").`);
38
+ }
39
+ }
40
+ }
8
41
  /**
9
42
  * Validate every admin config in a configuration.
10
43
  *
@@ -21,8 +54,9 @@
21
54
  * are unique within an admin config and don't shadow schema field names.
22
55
  * 5. Nesting — `tabSets` only appear in `layout.main`; rows contain only
23
56
  * schema field names; groups exclude tabSets and nested groups.
24
- * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
25
- * schema field name.
57
+ * 6. `fields` map sanity — every key in `admin.fields` is a dotted,
58
+ * index-free schema path resolving to a field declaration (top-level
59
+ * name or a path through group/array fields; never through blocks).
26
60
  * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
27
61
  * or a document-level column (`createdAt` / `updatedAt` / `path`), the
28
62
  * direction (when given) is `asc` | `desc`, and the option is rejected
@@ -52,9 +86,11 @@ export function validateAdminConfigs(admins, collections) {
52
86
  * `type: 'blocks'` field across the registered collections (blocks have
53
87
  * no global registry; the collections walk is the source of truth).
54
88
  * 2. Uniqueness — no two entries share a `blockType`.
55
- * 3. `fields` map sanity — every key matches a top-level field name of the
56
- * block (when the same `blockType` appears in several collections, the
57
- * union of their top-level field names is accepted).
89
+ * 3. `fields` map sanity — every key is a dotted, index-free schema path
90
+ * resolving to a field declaration of the block (top-level name or a
91
+ * path through group/array fields; never through a nested blocks field).
92
+ * When the same `blockType` appears in several collections, a key is
93
+ * accepted if it resolves in any declaration site (union semantics).
58
94
  *
59
95
  * Throws a plain `Error` for the same reason `validateAdminConfigs` does —
60
96
  * this runs at startup, before the logger is necessarily wired up.
@@ -62,34 +98,21 @@ export function validateAdminConfigs(admins, collections) {
62
98
  export function validateBlockAdminConfigs(blockAdmins, collections) {
63
99
  if (blockAdmins == null || blockAdmins.length === 0)
64
100
  return;
65
- // Collect blockType → union of top-level field names across every
66
- // declaration site (including blocks nested inside groups/arrays/blocks).
67
- const blockFieldNames = new Map();
68
- const walkBlock = (block) => {
69
- let names = blockFieldNames.get(block.blockType);
70
- if (names == null) {
71
- names = new Set();
72
- blockFieldNames.set(block.blockType, names);
73
- }
74
- for (const field of block.fields) {
75
- if ('name' in field)
76
- names.add(field.name);
77
- }
78
- walkFields(block.fields);
79
- };
80
- const walkFields = (fields) => {
81
- for (const field of fields) {
82
- if (field.type === 'blocks') {
83
- for (const block of field.blocks)
84
- walkBlock(block);
85
- }
86
- else if ('fields' in field && Array.isArray(field.fields)) {
87
- walkFields(field.fields);
88
- }
89
- }
90
- };
101
+ // Collect blockType → declaration sites across every registered collection
102
+ // (including blocks nested inside groups/arrays/blocks). Structural drift
103
+ // between same-blockType declarations is possible, so keys validate against
104
+ // the union of sites.
105
+ const blocksByType = new Map();
91
106
  for (const collection of collections) {
92
- walkFields(collection.fields);
107
+ walkFieldDeclarations(collection.fields, () => { }, {
108
+ onBlock: (block) => {
109
+ const sites = blocksByType.get(block.blockType);
110
+ if (sites == null)
111
+ blocksByType.set(block.blockType, [block]);
112
+ else
113
+ sites.push(block);
114
+ },
115
+ });
93
116
  }
94
117
  const seen = new Set();
95
118
  for (const entry of blockAdmins) {
@@ -99,17 +122,28 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
99
122
  }
100
123
  seen.add(entry.blockType);
101
124
  // Rule 1 — block pairing.
102
- const names = blockFieldNames.get(entry.blockType);
103
- if (names == null) {
125
+ const sites = blocksByType.get(entry.blockType);
126
+ if (sites == null) {
104
127
  throw new Error(`Block admin config "${entry.blockType}" has no matching block (no \`type: 'blocks'\` field of any registered collection declares a block with \`blockType: '${entry.blockType}'\`).`);
105
128
  }
106
- // Rule 3 — `fields` keys must be top-level field names of the block.
129
+ // Rule 3 — `fields` keys must be schema paths resolving within the block
130
+ // (union across declaration sites). 'blocks' beats 'unresolved' in the
131
+ // union so the traversal error surfaces when any site has the nested
132
+ // blocks field the key tried to walk through.
107
133
  if (entry.fields != null) {
108
- for (const key of Object.keys(entry.fields)) {
109
- if (!names.has(key)) {
110
- throw new Error(`Block "${entry.blockType}": \`fields["${key}"]\` references a name that is not a top-level field of the block. Per-field overrides apply only to the block's top-level fields.`);
134
+ validateFieldAdminKeys(Object.keys(entry.fields), (key) => {
135
+ let best = 'unresolved';
136
+ for (const block of sites) {
137
+ const resolution = resolveSchemaPath(block.fields, key);
138
+ if (resolution === 'ok')
139
+ return 'ok';
140
+ if (resolution === 'blocks')
141
+ best = 'blocks';
111
142
  }
112
- }
143
+ return best;
144
+ }, (msg) => {
145
+ throw new Error(`Block "${entry.blockType}": ${msg}`);
146
+ });
113
147
  }
114
148
  }
115
149
  }
@@ -272,13 +306,10 @@ function validateOne(admin, collectionsByPath) {
272
306
  }
273
307
  }
274
308
  }
275
- // Rule 6 — `fields` map keys must match top-level schema field names.
309
+ // Rule 6 — `fields` map keys must be schema paths resolving to a field
310
+ // declaration of the collection.
276
311
  if (admin.fields != null) {
277
- for (const key of Object.keys(admin.fields)) {
278
- if (!topLevelFieldNames.has(key)) {
279
- fail(`\`fields["${key}"]\` references a name that is not a top-level schema field. Per-field overrides apply only to top-level schema fields.`);
280
- }
281
- }
312
+ validateFieldAdminKeys(Object.keys(admin.fields), (key) => resolveSchemaPath(collection.fields, key), fail);
282
313
  }
283
314
  // Rule 2 + 3 — layout: name resolution + bookkeeping (every schema field
284
315
  // placed exactly once). Skipped when no `layout` is declared (the
@@ -199,7 +199,63 @@ describe('validateAdminConfigs', () => {
199
199
  ...baseAdmin,
200
200
  fields: { nonexistent: {} },
201
201
  };
202
- expect(() => validateAdminConfigs([admin], [collection])).toThrow(/not a top-level schema field/);
202
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/does not resolve to a field declaration/);
203
+ });
204
+ // Rule 6 — dotted schema-path keys. A dedicated collection with nested
205
+ // structure: files (array) → filesGroup (group) → leaf fields, plus a
206
+ // blocks field to prove traversal through blocks is rejected.
207
+ const nestedCollection = {
208
+ path: 'library',
209
+ labels: { singular: 'Item', plural: 'Items' },
210
+ useAsPath: 'title',
211
+ fields: [
212
+ { name: 'title', label: 'Title', type: 'text' },
213
+ {
214
+ name: 'files',
215
+ label: 'Files',
216
+ type: 'array',
217
+ fields: [
218
+ {
219
+ name: 'filesGroup',
220
+ type: 'group',
221
+ fields: [
222
+ { name: 'publicationFile', label: 'File', type: 'file' },
223
+ { name: 'notes', label: 'Notes', type: 'richText' },
224
+ ],
225
+ },
226
+ ],
227
+ },
228
+ {
229
+ name: 'sections',
230
+ label: 'Sections',
231
+ type: 'blocks',
232
+ blocks: [
233
+ {
234
+ blockType: 'proseBlock',
235
+ fields: [{ name: 'body', label: 'Body', type: 'richText' }],
236
+ },
237
+ ],
238
+ },
239
+ ],
240
+ };
241
+ const nestedAdmin = (fields) => ({
242
+ slug: 'library',
243
+ fields: fields,
244
+ });
245
+ it('accepts a dotted fields key through array and group declarations', () => {
246
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.notes': {} })], [nestedCollection])).not.toThrow();
247
+ });
248
+ it('rejects a dotted fields key whose leaf does not exist', () => {
249
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.missing': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
250
+ });
251
+ it('rejects a dotted fields key that walks through a value field', () => {
252
+ expect(() => validateAdminConfigs([nestedAdmin({ 'title.anything': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
253
+ });
254
+ it('rejects a fields key carrying an item index', () => {
255
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files[0].filesGroup.notes': {} })], [nestedCollection])).toThrow(/index-free schema paths/);
256
+ });
257
+ it('rejects a fields key that traverses a blocks field', () => {
258
+ expect(() => validateAdminConfigs([nestedAdmin({ 'sections.body': {} })], [nestedCollection])).toThrow(/blockAdmin/);
203
259
  });
204
260
  // Rule 7 — defaultSort sanity.
205
261
  it('accepts a defaultSort on a schema field', () => {
@@ -345,8 +401,42 @@ describe('validateBlockAdminConfigs', () => {
345
401
  expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock' }, { blockType: 'quoteBlock' }], [blockCollection])).toThrow(/registered more than once/);
346
402
  });
347
403
  // Rule 3 — fields map sanity.
348
- it('rejects a fields key that is not a top-level field of the block', () => {
349
- expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/not a top-level field of the block/);
404
+ it('rejects a fields key that is not a field of the block', () => {
405
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/does not resolve to a field declaration/);
406
+ });
407
+ // Rule 3 — dotted schema-path keys inside a block (array-in-block, the
408
+ // FAQBlock shape).
409
+ const faqBlock = {
410
+ blockType: 'faqBlock',
411
+ fields: [
412
+ {
413
+ name: 'faq',
414
+ label: 'Questions',
415
+ type: 'array',
416
+ fields: [
417
+ { name: 'question', label: 'Question', type: 'text' },
418
+ { name: 'answer', label: 'Answer', type: 'richText' },
419
+ ],
420
+ },
421
+ ],
422
+ };
423
+ const faqCollection = {
424
+ path: 'docs',
425
+ labels: { singular: 'Doc', plural: 'Docs' },
426
+ useAsPath: 'title',
427
+ fields: [
428
+ { name: 'title', label: 'Title', type: 'text' },
429
+ { name: 'content', label: 'Content', type: 'blocks', blocks: [faqBlock] },
430
+ ],
431
+ };
432
+ it('accepts a dotted fields key addressing a field inside an array in the block', () => {
433
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq.answer': {} } }], [faqCollection])).not.toThrow();
434
+ });
435
+ it('rejects a dotted fields key whose leaf is not declared in the block', () => {
436
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq.missing': {} } }], [faqCollection])).toThrow(/does not resolve to a field declaration/);
437
+ });
438
+ it('rejects a block fields key carrying an item index', () => {
439
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq[0].answer': {} } }], [faqCollection])).toThrow(/index-free schema paths/);
350
440
  });
351
441
  it('accepts the union of field names when a blockType appears in several collections', () => {
352
442
  const other = {
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
8
9
  import { fieldTypeToStore } from '../storage/field-store-map.js';
9
10
  /**
10
11
  * Field names that cannot be declared in a collection schema because they
@@ -61,37 +62,27 @@ function hasLocalizedField(fields) {
61
62
  });
62
63
  return found;
63
64
  }
65
+ /** Every field declaration, for checks that don't care where it sits. */
64
66
  function walkFields(fields, visit) {
65
- for (const field of fields) {
67
+ walkFieldDeclarations(fields, (field) => {
66
68
  visit(field);
67
- if (field.type === 'group' || field.type === 'array') {
68
- walkFields(field.fields, visit);
69
- }
70
- else if (field.type === 'blocks') {
71
- for (const block of field.blocks) {
72
- walkFields(block.fields, visit);
73
- }
74
- }
75
- }
69
+ });
76
70
  }
77
71
  /**
78
- * Path-aware variant of `walkFields` — visits every field with its dotted
79
- * schema path (e.g. `files.filesGroup.generateThumbnail`) so validation
72
+ * Path-aware variant of `walkFields` — visits every field with its
73
+ * declaration path (e.g. `files.filesGroup.generateThumbnail`) so validation
80
74
  * errors can point at the exact declaration site.
75
+ *
76
+ * Delegates to the shared grammar's canonical walk. It previously descended
77
+ * into blocks without recording the block type, which made the emitted path
78
+ * ambiguous whenever two blocks in one field declared the same field name —
79
+ * both rendered as `content.alt`, and the message could not say which was at
80
+ * fault. `walkFieldDeclarations` carries the block type through.
81
81
  */
82
- function walkFieldsWithPath(fields, visit, prefix = '') {
83
- for (const field of fields) {
84
- const fieldPath = prefix === '' ? field.name : `${prefix}.${field.name}`;
85
- visit(field, fieldPath);
86
- if (field.type === 'group' || field.type === 'array') {
87
- walkFieldsWithPath(field.fields, visit, fieldPath);
88
- }
89
- else if (field.type === 'blocks') {
90
- for (const block of field.blocks) {
91
- walkFieldsWithPath(block.fields, visit, fieldPath);
92
- }
93
- }
94
- }
82
+ function walkFieldsWithPath(fields, visit) {
83
+ walkFieldDeclarations(fields, (field, segments) => {
84
+ visit(field, formatDeclarationPath(segments));
85
+ });
95
86
  }
96
87
  /**
97
88
  * Top-level field names a collection's `search` config references (body +
@@ -114,6 +105,52 @@ function searchReferencedFieldNames(collection) {
114
105
  }
115
106
  return names;
116
107
  }
108
+ /**
109
+ * Enforce that every field named by a collection's `search` config resolves.
110
+ *
111
+ * `buildSearchDocument` looks each name up among the collection's **top-level**
112
+ * fields and silently skips anything it cannot resolve, so an unrecognised or
113
+ * dotted name produced a collection that indexed less than its author thought
114
+ * — with no error at boot and no signal at index time.
115
+ *
116
+ * Nested content is reachable, but by naming its top-level *container*: a
117
+ * `group` / `array` / `blocks` field named in `search.body` is walked
118
+ * recursively and every nested text leaf is flattened into the body. What is
119
+ * not supported is addressing one specific nested declaration, which is why a
120
+ * dotted path is rejected outright rather than accepted and ignored.
121
+ */
122
+ function validateSearchFields(collection) {
123
+ const search = collection.search;
124
+ if (search == null)
125
+ return;
126
+ const topLevel = (name) => collection.fields.find((f) => 'name' in f && f.name === name);
127
+ const check = (name, option) => {
128
+ if (name.includes('.')) {
129
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but search config addresses top-level fields only. To index content nested inside a \`group\` / \`array\` / \`blocks\` field, name the top-level container — it is walked recursively and every nested text leaf is flattened into the body.`);
130
+ }
131
+ const field = topLevel(name);
132
+ if (field == null) {
133
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but no top-level field with that name exists.`);
134
+ }
135
+ return field;
136
+ };
137
+ for (const decl of search.body ?? []) {
138
+ check(typeof decl === 'string' ? decl : decl.field, 'body');
139
+ }
140
+ for (const decl of search.facets ?? []) {
141
+ const name = typeof decl === 'string' ? decl : decl.field;
142
+ const field = check(name, 'facets');
143
+ if (field.type !== 'relation') {
144
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`search.facets\` but field "${name}" has type "${field.type}". Facets are controlled-vocabulary references — name a \`relation\` field.`);
145
+ }
146
+ }
147
+ for (const name of search.filters ?? []) {
148
+ const field = check(name, 'filters');
149
+ if (field.type === 'group' || field.type === 'array' || field.type === 'blocks') {
150
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`search.filters\` but field "${name}" has type "${field.type}". Filters project a single scalar value — name a value field.`);
151
+ }
152
+ }
153
+ }
117
154
  /**
118
155
  * Enforce the `virtual` field constraints for one collection. See
119
156
  * `BaseField.virtual` in field-types.ts for the contract these rules back:
@@ -236,6 +273,7 @@ export function validateCollections(collections) {
236
273
  if (collection.tree === true && collection.orderable === true) {
237
274
  throw new Error(`Collection "${collection.path}" sets both \`tree: true\` and \`orderable: true\`. A document-tree collection owns ordering on the tree edge (per-parent), so \`byline_documents.order_key\` is inert — set only \`tree: true\`.`);
238
275
  }
276
+ validateSearchFields(collection);
239
277
  validateVirtualFields(collection);
240
278
  validateUploadLocations(collection);
241
279
  }