@byline/core 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/admin-types.d.ts +26 -9
- package/dist/@types/admin-types.js +4 -2
- package/dist/config/routes.test.node.js +1 -7
- package/dist/config/validate-admin-configs.d.ts +8 -5
- package/dist/config/validate-admin-configs.js +94 -29
- package/dist/config/validate-admin-configs.test.node.js +93 -3
- package/dist/schemas/zod/builder.js +26 -2
- package/dist/schemas/zod/builder.test.node.js +72 -0
- package/dist/storage/collection-fingerprint.test.node.js +1 -4
- package/package.json +6 -6
|
@@ -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
|
|
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
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
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
|
|
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`
|
|
426
|
-
*
|
|
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
|
-
|
|
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`
|
|
21
|
-
*
|
|
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
|
|
@@ -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`
|
|
26
|
-
* schema field
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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,61 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a dotted schema path (`faq.answer`, `files.filesGroup.publicationFile`)
|
|
10
|
+
* against a field set. Schema paths address field *declarations*: every
|
|
11
|
+
* segment names a field, intermediate segments must be `group` / `array`
|
|
12
|
+
* structure fields, and no segment carries an item index. Returns:
|
|
13
|
+
*
|
|
14
|
+
* - `'ok'` — the path resolves to a field declaration;
|
|
15
|
+
* - `'blocks'` — the path tries to traverse a `type: 'blocks'` field
|
|
16
|
+
* (blocks resolve their own admin config from the
|
|
17
|
+
* blockType-keyed registry, so this is always an error);
|
|
18
|
+
* - `'unresolved'` — a segment doesn't name a field, or a value field
|
|
19
|
+
* appears mid-path.
|
|
20
|
+
*/
|
|
21
|
+
function resolveSchemaPath(fields, path) {
|
|
22
|
+
const segments = path.split('.');
|
|
23
|
+
let current = fields;
|
|
24
|
+
for (let i = 0; i < segments.length; i++) {
|
|
25
|
+
const name = segments[i];
|
|
26
|
+
const field = current.find((f) => 'name' in f && f.name === name);
|
|
27
|
+
if (field == null)
|
|
28
|
+
return 'unresolved';
|
|
29
|
+
if (i === segments.length - 1)
|
|
30
|
+
return 'ok';
|
|
31
|
+
if (field.type === 'group' || field.type === 'array') {
|
|
32
|
+
current = field.fields;
|
|
33
|
+
}
|
|
34
|
+
else if (field.type === 'blocks') {
|
|
35
|
+
return 'blocks';
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
return 'unresolved';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return 'unresolved';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Shared validation for a `fields{}` override map (collection- or block-level):
|
|
45
|
+
* keys must be dotted, index-free schema paths that resolve to a field
|
|
46
|
+
* declaration without traversing a `blocks` field. `subject` names the config
|
|
47
|
+
* in error messages (`Collection "docs"` / `Block "faqBlock"`).
|
|
48
|
+
*/
|
|
49
|
+
function validateFieldAdminKeys(keys, resolve, fail) {
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
if (key.includes('[')) {
|
|
52
|
+
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").`);
|
|
53
|
+
}
|
|
54
|
+
const resolution = resolve(key);
|
|
55
|
+
if (resolution === 'blocks') {
|
|
56
|
+
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.`);
|
|
57
|
+
}
|
|
58
|
+
if (resolution === 'unresolved') {
|
|
59
|
+
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").`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
8
63
|
/**
|
|
9
64
|
* Validate every admin config in a configuration.
|
|
10
65
|
*
|
|
@@ -21,8 +76,9 @@
|
|
|
21
76
|
* are unique within an admin config and don't shadow schema field names.
|
|
22
77
|
* 5. Nesting — `tabSets` only appear in `layout.main`; rows contain only
|
|
23
78
|
* schema field names; groups exclude tabSets and nested groups.
|
|
24
|
-
* 6. `fields` map sanity — every key in `admin.fields`
|
|
25
|
-
* schema field
|
|
79
|
+
* 6. `fields` map sanity — every key in `admin.fields` is a dotted,
|
|
80
|
+
* index-free schema path resolving to a field declaration (top-level
|
|
81
|
+
* name or a path through group/array fields; never through blocks).
|
|
26
82
|
* 7. `defaultSort` sanity — `field` resolves to a top-level schema field
|
|
27
83
|
* or a document-level column (`createdAt` / `updatedAt` / `path`), the
|
|
28
84
|
* direction (when given) is `asc` | `desc`, and the option is rejected
|
|
@@ -52,9 +108,11 @@ export function validateAdminConfigs(admins, collections) {
|
|
|
52
108
|
* `type: 'blocks'` field across the registered collections (blocks have
|
|
53
109
|
* no global registry; the collections walk is the source of truth).
|
|
54
110
|
* 2. Uniqueness — no two entries share a `blockType`.
|
|
55
|
-
* 3. `fields` map sanity — every key
|
|
56
|
-
*
|
|
57
|
-
*
|
|
111
|
+
* 3. `fields` map sanity — every key is a dotted, index-free schema path
|
|
112
|
+
* resolving to a field declaration of the block (top-level name or a
|
|
113
|
+
* path through group/array fields; never through a nested blocks field).
|
|
114
|
+
* When the same `blockType` appears in several collections, a key is
|
|
115
|
+
* accepted if it resolves in any declaration site (union semantics).
|
|
58
116
|
*
|
|
59
117
|
* Throws a plain `Error` for the same reason `validateAdminConfigs` does —
|
|
60
118
|
* this runs at startup, before the logger is necessarily wired up.
|
|
@@ -62,19 +120,18 @@ export function validateAdminConfigs(admins, collections) {
|
|
|
62
120
|
export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
63
121
|
if (blockAdmins == null || blockAdmins.length === 0)
|
|
64
122
|
return;
|
|
65
|
-
// Collect blockType →
|
|
66
|
-
//
|
|
67
|
-
|
|
123
|
+
// Collect blockType → declaration sites across every registered collection
|
|
124
|
+
// (including blocks nested inside groups/arrays/blocks). Structural drift
|
|
125
|
+
// between same-blockType declarations is possible, so keys validate against
|
|
126
|
+
// the union of sites.
|
|
127
|
+
const blocksByType = new Map();
|
|
68
128
|
const walkBlock = (block) => {
|
|
69
|
-
let
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
for (const field of block.fields) {
|
|
75
|
-
if ('name' in field)
|
|
76
|
-
names.add(field.name);
|
|
129
|
+
let sites = blocksByType.get(block.blockType);
|
|
130
|
+
if (sites == null) {
|
|
131
|
+
sites = [];
|
|
132
|
+
blocksByType.set(block.blockType, sites);
|
|
77
133
|
}
|
|
134
|
+
sites.push(block);
|
|
78
135
|
walkFields(block.fields);
|
|
79
136
|
};
|
|
80
137
|
const walkFields = (fields) => {
|
|
@@ -99,17 +156,28 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
|
99
156
|
}
|
|
100
157
|
seen.add(entry.blockType);
|
|
101
158
|
// Rule 1 — block pairing.
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
159
|
+
const sites = blocksByType.get(entry.blockType);
|
|
160
|
+
if (sites == null) {
|
|
104
161
|
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
162
|
}
|
|
106
|
-
// Rule 3 — `fields` keys must be
|
|
163
|
+
// Rule 3 — `fields` keys must be schema paths resolving within the block
|
|
164
|
+
// (union across declaration sites). 'blocks' beats 'unresolved' in the
|
|
165
|
+
// union so the traversal error surfaces when any site has the nested
|
|
166
|
+
// blocks field the key tried to walk through.
|
|
107
167
|
if (entry.fields != null) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
168
|
+
validateFieldAdminKeys(Object.keys(entry.fields), (key) => {
|
|
169
|
+
let best = 'unresolved';
|
|
170
|
+
for (const block of sites) {
|
|
171
|
+
const resolution = resolveSchemaPath(block.fields, key);
|
|
172
|
+
if (resolution === 'ok')
|
|
173
|
+
return 'ok';
|
|
174
|
+
if (resolution === 'blocks')
|
|
175
|
+
best = 'blocks';
|
|
111
176
|
}
|
|
112
|
-
|
|
177
|
+
return best;
|
|
178
|
+
}, (msg) => {
|
|
179
|
+
throw new Error(`Block "${entry.blockType}": ${msg}`);
|
|
180
|
+
});
|
|
113
181
|
}
|
|
114
182
|
}
|
|
115
183
|
}
|
|
@@ -272,13 +340,10 @@ function validateOne(admin, collectionsByPath) {
|
|
|
272
340
|
}
|
|
273
341
|
}
|
|
274
342
|
}
|
|
275
|
-
// Rule 6 — `fields` map keys must
|
|
343
|
+
// Rule 6 — `fields` map keys must be schema paths resolving to a field
|
|
344
|
+
// declaration of the collection.
|
|
276
345
|
if (admin.fields != null) {
|
|
277
|
-
|
|
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
|
-
}
|
|
346
|
+
validateFieldAdminKeys(Object.keys(admin.fields), (key) => resolveSchemaPath(collection.fields, key), fail);
|
|
282
347
|
}
|
|
283
348
|
// Rule 2 + 3 — layout: name resolution + bookkeeping (every schema field
|
|
284
349
|
// 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
|
|
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
|
|
349
|
-
expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/not a
|
|
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 = {
|
|
@@ -48,9 +48,33 @@ const _applyDateTimeValidation = (schema, _field) => {
|
|
|
48
48
|
export const fieldToZodSchema = (field, strict = true) => {
|
|
49
49
|
let schema;
|
|
50
50
|
switch (field.type) {
|
|
51
|
-
case 'array':
|
|
52
|
-
|
|
51
|
+
case 'array': {
|
|
52
|
+
// Homogeneous item objects built by recursing into the array's child
|
|
53
|
+
// fields — the item shape mirrors `createFieldsSchema` one level down,
|
|
54
|
+
// so requiredness and per-type validation apply inside items exactly
|
|
55
|
+
// as they do at the top level (threading `strict` keeps reads lenient
|
|
56
|
+
// for schema-evolved documents). `_id` is the synthetic item identity
|
|
57
|
+
// (assigned client-side on add, persisted via store_meta) — always
|
|
58
|
+
// accepted, never required. `.passthrough()` tolerates auxiliary keys
|
|
59
|
+
// (e.g. legacy seed-data `id` aliases) instead of stripping them.
|
|
60
|
+
// Group children stay `z.any()` via the `group` case below — the same
|
|
61
|
+
// depth boundary top-level groups have.
|
|
62
|
+
const itemShape = {
|
|
63
|
+
_id: z.string().optional(),
|
|
64
|
+
};
|
|
65
|
+
for (const child of field.fields ?? []) {
|
|
66
|
+
itemShape[child.name] = fieldToZodSchema(child, strict);
|
|
67
|
+
}
|
|
68
|
+
let arraySchema = z.array(z.object(itemShape).passthrough());
|
|
69
|
+
if (field.validation?.minLength != null) {
|
|
70
|
+
arraySchema = arraySchema.min(field.validation.minLength);
|
|
71
|
+
}
|
|
72
|
+
if (field.validation?.maxLength != null) {
|
|
73
|
+
arraySchema = arraySchema.max(field.validation.maxLength);
|
|
74
|
+
}
|
|
75
|
+
schema = arraySchema;
|
|
53
76
|
break;
|
|
77
|
+
}
|
|
54
78
|
case 'text': {
|
|
55
79
|
let textSchema = z.string();
|
|
56
80
|
textSchema = applyTextValidation(textSchema, field);
|
|
@@ -47,3 +47,75 @@ describe('collection Zod schemas', () => {
|
|
|
47
47
|
expect(result.success).toBe(false);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
// Array items recurse into the child field schemas (the FAQBlock shape:
|
|
51
|
+
// multiple direct value fields per item, plus an optional group child that
|
|
52
|
+
// stays permissive — the same depth boundary top-level groups have).
|
|
53
|
+
const Faqs = defineCollection({
|
|
54
|
+
path: 'faqs',
|
|
55
|
+
labels: { singular: 'FAQ', plural: 'FAQs' },
|
|
56
|
+
fields: [
|
|
57
|
+
{ name: 'title', type: 'text' },
|
|
58
|
+
{
|
|
59
|
+
name: 'faq',
|
|
60
|
+
type: 'array',
|
|
61
|
+
validation: { minLength: 1 },
|
|
62
|
+
fields: [
|
|
63
|
+
{ name: 'question', type: 'text' },
|
|
64
|
+
{ name: 'answer', type: 'richText', optional: true },
|
|
65
|
+
{
|
|
66
|
+
name: 'meta',
|
|
67
|
+
type: 'group',
|
|
68
|
+
optional: true,
|
|
69
|
+
fields: [{ name: 'anchor', type: 'text' }],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const faqItem = { question: 'What is Byline?', answer: { root: {} } };
|
|
76
|
+
describe('array item schemas', () => {
|
|
77
|
+
const schemas = createCollectionSchemas(Faqs);
|
|
78
|
+
it('accepts complete items, with or without the synthetic _id', () => {
|
|
79
|
+
const result = schemas.fields.safeParse({
|
|
80
|
+
title: 'FAQ page',
|
|
81
|
+
faq: [faqItem, { ...faqItem, _id: '0198c0de-0000-7000-8000-000000000001' }],
|
|
82
|
+
});
|
|
83
|
+
expect(result.success).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('rejects an item missing a required child field in the strict schema', () => {
|
|
86
|
+
const result = schemas.fields.safeParse({
|
|
87
|
+
title: 'FAQ page',
|
|
88
|
+
faq: [{ answer: { root: {} } }],
|
|
89
|
+
});
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
it('rejects a wrongly typed child value in the strict schema', () => {
|
|
93
|
+
const result = schemas.fields.safeParse({
|
|
94
|
+
title: 'FAQ page',
|
|
95
|
+
faq: [{ ...faqItem, question: 42 }],
|
|
96
|
+
});
|
|
97
|
+
expect(result.success).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
it('enforces the array-level minLength bound', () => {
|
|
100
|
+
const result = schemas.fields.safeParse({ title: 'FAQ page', faq: [] });
|
|
101
|
+
expect(result.success).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it('leaves group children permissive inside items', () => {
|
|
104
|
+
const result = schemas.fields.safeParse({
|
|
105
|
+
title: 'FAQ page',
|
|
106
|
+
faq: [{ ...faqItem, meta: { anything: true } }],
|
|
107
|
+
});
|
|
108
|
+
expect(result.success).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it('keeps reads lenient — items with missing children still parse in the get schema', () => {
|
|
111
|
+
const parsed = schemas.get.parse({
|
|
112
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
113
|
+
status: 'draft',
|
|
114
|
+
createdAt: '2026-07-15T12:00:00.000Z',
|
|
115
|
+
updatedAt: '2026-07-15T12:00:00.000Z',
|
|
116
|
+
// A schema-evolved document: item pre-dates the `question` field.
|
|
117
|
+
fields: { title: 'FAQ page', faq: [{ _id: 'abc', answer: null }] },
|
|
118
|
+
});
|
|
119
|
+
expect(parsed.fields.faq).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -152,10 +152,7 @@ describe('fingerprintCollection', () => {
|
|
|
152
152
|
rel.hasMany = true;
|
|
153
153
|
expect(await fingerprintCollection(b)).not.toBe(a);
|
|
154
154
|
});
|
|
155
|
-
it.each([
|
|
156
|
-
'minItems',
|
|
157
|
-
'maxItems',
|
|
158
|
-
])('changes when relation %s changes', async (constraint) => {
|
|
155
|
+
it.each(['minItems', 'maxItems'])('changes when relation %s changes', async (constraint) => {
|
|
159
156
|
const a = baseCollection();
|
|
160
157
|
const aRel = a.fields.find((f) => f.name === 'category');
|
|
161
158
|
aRel.hasMany = true;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.3.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -81,16 +81,16 @@
|
|
|
81
81
|
"pino": "^10.3.1",
|
|
82
82
|
"sharp": "^0.35.3",
|
|
83
83
|
"zod": "^4.4.3",
|
|
84
|
-
"@byline/auth": "4.
|
|
84
|
+
"@byline/auth": "4.3.0"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@biomejs/biome": "2.5.
|
|
88
|
-
"@types/node": "^26.1.
|
|
87
|
+
"@biomejs/biome": "2.5.4",
|
|
88
|
+
"@types/node": "^26.1.1",
|
|
89
89
|
"chokidar": "^5.0.0",
|
|
90
90
|
"chokidar-cli": "^3.0.0",
|
|
91
91
|
"npm-run-all": "^4.1.5",
|
|
92
|
-
"tsc-alias": "^1.9.
|
|
93
|
-
"tsx": "^4.23.
|
|
92
|
+
"tsc-alias": "^1.9.1",
|
|
93
|
+
"tsx": "^4.23.1",
|
|
94
94
|
"typescript": "^7.0.2",
|
|
95
95
|
"vitest": "^4.1.10"
|
|
96
96
|
},
|