@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.
- package/dist/@types/admin-types.d.ts +26 -9
- package/dist/@types/admin-types.js +4 -2
- package/dist/@types/collection-types.d.ts +9 -3
- package/dist/config/attach-hooks.js +19 -24
- 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 +76 -45
- package/dist/config/validate-admin-configs.test.node.js +93 -3
- package/dist/config/validate-collections.js +63 -25
- package/dist/config/validate-collections.test.node.js +63 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/patches/apply-patches.js +20 -29
- package/dist/patches/patch.test.node.js +49 -1
- package/dist/paths/index.d.ts +16 -0
- package/dist/paths/index.js +15 -0
- package/dist/paths/parse-path.d.ts +44 -0
- package/dist/paths/parse-path.js +133 -0
- package/dist/paths/path-dialects.test.node.d.ts +8 -0
- package/dist/paths/path-dialects.test.node.js +267 -0
- package/dist/paths/path-types.d.ts +86 -0
- package/dist/paths/path-types.js +8 -0
- package/dist/paths/paths.test.node.d.ts +8 -0
- package/dist/paths/paths.test.node.js +318 -0
- package/dist/paths/resolve-path.d.ts +47 -0
- package/dist/paths/resolve-path.js +116 -0
- package/dist/schemas/zod/builder.js +26 -2
- package/dist/schemas/zod/builder.test.node.js +72 -0
- package/dist/services/assign-counter-values.js +9 -3
- 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
|
|
@@ -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
|
|
1157
|
-
* `
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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`
|
|
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,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`
|
|
25
|
-
* schema field
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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 →
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
if (
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
@@ -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
|
-
|
|
67
|
+
walkFieldDeclarations(fields, (field) => {
|
|
66
68
|
visit(field);
|
|
67
|
-
|
|
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
|
|
79
|
-
*
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|