@byline/core 4.1.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 +92 -3
- package/dist/@types/admin-types.js +31 -0
- package/dist/@types/collection-types.d.ts +29 -7
- package/dist/@types/db-types.d.ts +7 -7
- package/dist/@types/field-data-types.d.ts +1 -0
- package/dist/@types/field-types.d.ts +36 -1
- package/dist/@types/site-config.d.ts +28 -1
- package/dist/@types/storage-types.d.ts +14 -1
- package/dist/codegen/fixtures/all-fields.d.ts +23 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +4 -0
- package/dist/codegen/fixtures/all-fields.js +2 -0
- package/dist/codegen/index.js +2 -0
- package/dist/config/config.js +2 -1
- package/dist/config/routes.test.node.js +1 -7
- package/dist/config/validate-admin-configs.d.ts +22 -3
- package/dist/config/validate-admin-configs.js +157 -19
- package/dist/config/validate-admin-configs.test.node.js +205 -2
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +60 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/schemas/zod/builder.js +37 -2
- package/dist/schemas/zod/builder.test.node.js +72 -0
- package/dist/services/build-search-document.js +5 -1
- package/dist/services/document-to-markdown.js +10 -0
- package/dist/services/field-upload.d.ts +7 -0
- package/dist/services/field-upload.js +15 -13
- package/dist/services/field-upload.test.node.js +1 -1
- package/dist/services/populate.d.ts +1 -1
- package/dist/services/populate.js +1 -1
- package/dist/storage/collection-fingerprint.js +9 -0
- package/dist/storage/collection-fingerprint.test.node.js +1 -4
- package/dist/storage/field-store-map.js +1 -0
- package/dist/storage/field-store-map.test.node.js +1 -0
- package/dist/utils/slugify-filename.d.ts +50 -0
- package/dist/utils/slugify-filename.js +36 -0
- package/dist/utils/slugify-filename.test.node.d.ts +8 -0
- package/dist/utils/slugify-filename.test.node.js +50 -0
- package/package.json +6 -6
|
@@ -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
|
|
@@ -44,6 +100,87 @@ export function validateAdminConfigs(admins, collections) {
|
|
|
44
100
|
validateOne(admin, collectionsByPath);
|
|
45
101
|
}
|
|
46
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate every block admin config in a configuration.
|
|
105
|
+
*
|
|
106
|
+
* Enforced rules (per `ClientConfig.blockAdmin` entry):
|
|
107
|
+
* 1. Block pairing — `blockType` matches a block declared on at least one
|
|
108
|
+
* `type: 'blocks'` field across the registered collections (blocks have
|
|
109
|
+
* no global registry; the collections walk is the source of truth).
|
|
110
|
+
* 2. Uniqueness — no two entries share a `blockType`.
|
|
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).
|
|
116
|
+
*
|
|
117
|
+
* Throws a plain `Error` for the same reason `validateAdminConfigs` does —
|
|
118
|
+
* this runs at startup, before the logger is necessarily wired up.
|
|
119
|
+
*/
|
|
120
|
+
export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
121
|
+
if (blockAdmins == null || blockAdmins.length === 0)
|
|
122
|
+
return;
|
|
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();
|
|
128
|
+
const walkBlock = (block) => {
|
|
129
|
+
let sites = blocksByType.get(block.blockType);
|
|
130
|
+
if (sites == null) {
|
|
131
|
+
sites = [];
|
|
132
|
+
blocksByType.set(block.blockType, sites);
|
|
133
|
+
}
|
|
134
|
+
sites.push(block);
|
|
135
|
+
walkFields(block.fields);
|
|
136
|
+
};
|
|
137
|
+
const walkFields = (fields) => {
|
|
138
|
+
for (const field of fields) {
|
|
139
|
+
if (field.type === 'blocks') {
|
|
140
|
+
for (const block of field.blocks)
|
|
141
|
+
walkBlock(block);
|
|
142
|
+
}
|
|
143
|
+
else if ('fields' in field && Array.isArray(field.fields)) {
|
|
144
|
+
walkFields(field.fields);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
for (const collection of collections) {
|
|
149
|
+
walkFields(collection.fields);
|
|
150
|
+
}
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
for (const entry of blockAdmins) {
|
|
153
|
+
// Rule 2 — uniqueness.
|
|
154
|
+
if (seen.has(entry.blockType)) {
|
|
155
|
+
throw new Error(`Block admin config "${entry.blockType}" is registered more than once in \`blockAdmin\`.`);
|
|
156
|
+
}
|
|
157
|
+
seen.add(entry.blockType);
|
|
158
|
+
// Rule 1 — block pairing.
|
|
159
|
+
const sites = blocksByType.get(entry.blockType);
|
|
160
|
+
if (sites == null) {
|
|
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}'\`).`);
|
|
162
|
+
}
|
|
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.
|
|
167
|
+
if (entry.fields != null) {
|
|
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';
|
|
176
|
+
}
|
|
177
|
+
return best;
|
|
178
|
+
}, (msg) => {
|
|
179
|
+
throw new Error(`Block "${entry.blockType}": ${msg}`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
47
184
|
function validateOne(admin, collectionsByPath) {
|
|
48
185
|
// Rule 1 — slug pairing.
|
|
49
186
|
const collection = collectionsByPath.get(admin.slug);
|
|
@@ -62,26 +199,30 @@ function validateOne(admin, collectionsByPath) {
|
|
|
62
199
|
if ('name' in field)
|
|
63
200
|
topLevelFieldNames.add(field.name);
|
|
64
201
|
}
|
|
65
|
-
// Rule 7 — defaultSort sanity. The list read applies
|
|
66
|
-
// (host list server fn → parseSort), so a bad field name
|
|
67
|
-
// fall back to `created_at desc` at request time — fail
|
|
68
|
-
// instead.
|
|
69
|
-
|
|
70
|
-
|
|
202
|
+
// Rule 7 — defaultSort / itemViewSort sanity. The list read applies these
|
|
203
|
+
// specs verbatim (host list server fn → parseSort), so a bad field name
|
|
204
|
+
// would silently fall back to `created_at desc` at request time — fail
|
|
205
|
+
// loudly at boot instead. Both options share one rule set.
|
|
206
|
+
const validateSortSpec = (spec, optionName) => {
|
|
207
|
+
if (spec == null)
|
|
208
|
+
return;
|
|
209
|
+
const { field, direction } = spec;
|
|
71
210
|
const documentColumns = new Set(['createdAt', 'updatedAt', 'path']);
|
|
72
211
|
if (collection.orderable === true) {
|
|
73
|
-
fail(
|
|
212
|
+
fail(`${optionName} is not allowed on an orderable collection — manual ordering owns the sort (order_key asc).`);
|
|
74
213
|
}
|
|
75
214
|
if (typeof field !== 'string' || field.length === 0) {
|
|
76
|
-
fail(
|
|
215
|
+
fail(`${optionName}.field must be a non-empty string.`);
|
|
77
216
|
}
|
|
78
217
|
if (!topLevelFieldNames.has(field) && !documentColumns.has(field)) {
|
|
79
|
-
fail(
|
|
218
|
+
fail(`${optionName}.field "${String(field)}" is not a top-level schema field or a document column (createdAt, updatedAt, path).`);
|
|
80
219
|
}
|
|
81
220
|
if (direction != null && direction !== 'asc' && direction !== 'desc') {
|
|
82
|
-
fail(
|
|
221
|
+
fail(`${optionName}.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
|
|
83
222
|
}
|
|
84
|
-
}
|
|
223
|
+
};
|
|
224
|
+
validateSortSpec(admin.defaultSort, 'defaultSort');
|
|
225
|
+
validateSortSpec(admin.itemViewSort, 'itemViewSort');
|
|
85
226
|
// Build primitive lookup tables.
|
|
86
227
|
const tabSets = admin.tabSets ?? [];
|
|
87
228
|
const rows = admin.rows ?? [];
|
|
@@ -199,13 +340,10 @@ function validateOne(admin, collectionsByPath) {
|
|
|
199
340
|
}
|
|
200
341
|
}
|
|
201
342
|
}
|
|
202
|
-
// 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.
|
|
203
345
|
if (admin.fields != null) {
|
|
204
|
-
|
|
205
|
-
if (!topLevelFieldNames.has(key)) {
|
|
206
|
-
fail(`\`fields["${key}"]\` references a name that is not a top-level schema field. Per-field overrides apply only to top-level schema fields.`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
346
|
+
validateFieldAdminKeys(Object.keys(admin.fields), (key) => resolveSchemaPath(collection.fields, key), fail);
|
|
209
347
|
}
|
|
210
348
|
// Rule 2 + 3 — layout: name resolution + bookkeeping (every schema field
|
|
211
349
|
// placed exactly once). Skipped when no `layout` is declared (the
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import { validateAdminConfigs } from './validate-admin-configs.js';
|
|
9
|
+
import { validateAdminConfigs, validateBlockAdminConfigs } from './validate-admin-configs.js';
|
|
10
10
|
const collection = {
|
|
11
11
|
path: 'news',
|
|
12
12
|
labels: { singular: 'News', plural: 'News' },
|
|
@@ -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', () => {
|
|
@@ -238,6 +294,44 @@ describe('validateAdminConfigs', () => {
|
|
|
238
294
|
};
|
|
239
295
|
expect(() => validateAdminConfigs([admin], [orderableCollection])).toThrow(/not allowed on an orderable collection/);
|
|
240
296
|
});
|
|
297
|
+
// Rule 7 — itemViewSort shares the defaultSort rule set.
|
|
298
|
+
it('accepts an itemViewSort on a schema field', () => {
|
|
299
|
+
const admin = {
|
|
300
|
+
...baseAdmin,
|
|
301
|
+
itemViewSort: { field: 'title' },
|
|
302
|
+
};
|
|
303
|
+
expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
|
|
304
|
+
});
|
|
305
|
+
it('accepts independent defaultSort and itemViewSort', () => {
|
|
306
|
+
const admin = {
|
|
307
|
+
...baseAdmin,
|
|
308
|
+
defaultSort: { field: 'updatedAt', direction: 'desc' },
|
|
309
|
+
itemViewSort: { field: 'title' },
|
|
310
|
+
};
|
|
311
|
+
expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
|
|
312
|
+
});
|
|
313
|
+
it('rejects an itemViewSort field that resolves to nothing', () => {
|
|
314
|
+
const admin = {
|
|
315
|
+
...baseAdmin,
|
|
316
|
+
itemViewSort: { field: 'nonexistent' },
|
|
317
|
+
};
|
|
318
|
+
expect(() => validateAdminConfigs([admin], [collection])).toThrow(/itemViewSort\.field "nonexistent"/);
|
|
319
|
+
});
|
|
320
|
+
it('rejects an itemViewSort direction outside asc/desc', () => {
|
|
321
|
+
const admin = {
|
|
322
|
+
...baseAdmin,
|
|
323
|
+
itemViewSort: { field: 'title', direction: 'sideways' },
|
|
324
|
+
};
|
|
325
|
+
expect(() => validateAdminConfigs([admin], [collection])).toThrow(/itemViewSort\.direction must be 'asc' or 'desc'/);
|
|
326
|
+
});
|
|
327
|
+
it('rejects an itemViewSort on an orderable collection', () => {
|
|
328
|
+
const orderableCollection = { ...collection, orderable: true };
|
|
329
|
+
const admin = {
|
|
330
|
+
...baseAdmin,
|
|
331
|
+
itemViewSort: { field: 'title' },
|
|
332
|
+
};
|
|
333
|
+
expect(() => validateAdminConfigs([admin], [orderableCollection])).toThrow(/not allowed on an orderable collection/);
|
|
334
|
+
});
|
|
241
335
|
// Layout omitted — bookkeeping skipped, primitive declarations still checked.
|
|
242
336
|
it('skips placement bookkeeping when layout is omitted', () => {
|
|
243
337
|
const admin = {
|
|
@@ -258,3 +352,112 @@ describe('validateAdminConfigs', () => {
|
|
|
258
352
|
expect(() => validateAdminConfigs([admin], [collection])).toThrow(/collides with a schema field/);
|
|
259
353
|
});
|
|
260
354
|
});
|
|
355
|
+
describe('validateBlockAdminConfigs', () => {
|
|
356
|
+
const quoteBlock = {
|
|
357
|
+
blockType: 'quoteBlock',
|
|
358
|
+
fields: [
|
|
359
|
+
{ name: 'quoteText', label: 'Quote', type: 'richText' },
|
|
360
|
+
{ name: 'source', label: 'Source', type: 'text' },
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
const heroBlock = {
|
|
364
|
+
blockType: 'heroBlock',
|
|
365
|
+
fields: [{ name: 'heading', label: 'Heading', type: 'text' }],
|
|
366
|
+
};
|
|
367
|
+
const blockCollection = {
|
|
368
|
+
path: 'pages',
|
|
369
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
370
|
+
useAsPath: 'title',
|
|
371
|
+
fields: [
|
|
372
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
373
|
+
{ name: 'content', label: 'Content', type: 'blocks', blocks: [quoteBlock] },
|
|
374
|
+
{
|
|
375
|
+
name: 'meta',
|
|
376
|
+
label: 'Meta',
|
|
377
|
+
type: 'group',
|
|
378
|
+
fields: [
|
|
379
|
+
// Blocks fields nested inside structure fields are still collected.
|
|
380
|
+
{ name: 'panels', label: 'Panels', type: 'blocks', blocks: [heroBlock] },
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
it('accepts a valid block admin config (baseline)', () => {
|
|
386
|
+
expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { quoteText: {} } }], [blockCollection])).not.toThrow();
|
|
387
|
+
});
|
|
388
|
+
it('is a no-op when blockAdmins is undefined or empty', () => {
|
|
389
|
+
expect(() => validateBlockAdminConfigs(undefined, [blockCollection])).not.toThrow();
|
|
390
|
+
expect(() => validateBlockAdminConfigs([], [blockCollection])).not.toThrow();
|
|
391
|
+
});
|
|
392
|
+
// Rule 1 — block pairing.
|
|
393
|
+
it('rejects an entry whose blockType matches no declared block', () => {
|
|
394
|
+
expect(() => validateBlockAdminConfigs([{ blockType: 'missingBlock' }], [blockCollection])).toThrow(/no matching block/);
|
|
395
|
+
});
|
|
396
|
+
it('finds blocks declared inside nested structure fields', () => {
|
|
397
|
+
expect(() => validateBlockAdminConfigs([{ blockType: 'heroBlock', fields: { heading: {} } }], [blockCollection])).not.toThrow();
|
|
398
|
+
});
|
|
399
|
+
// Rule 2 — uniqueness.
|
|
400
|
+
it('rejects duplicate blockType entries', () => {
|
|
401
|
+
expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock' }, { blockType: 'quoteBlock' }], [blockCollection])).toThrow(/registered more than once/);
|
|
402
|
+
});
|
|
403
|
+
// Rule 3 — fields map sanity.
|
|
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/);
|
|
440
|
+
});
|
|
441
|
+
it('accepts the union of field names when a blockType appears in several collections', () => {
|
|
442
|
+
const other = {
|
|
443
|
+
path: 'docs',
|
|
444
|
+
labels: { singular: 'Doc', plural: 'Docs' },
|
|
445
|
+
useAsPath: 'title',
|
|
446
|
+
fields: [
|
|
447
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
448
|
+
{
|
|
449
|
+
name: 'content',
|
|
450
|
+
label: 'Content',
|
|
451
|
+
type: 'blocks',
|
|
452
|
+
blocks: [
|
|
453
|
+
{
|
|
454
|
+
blockType: 'quoteBlock',
|
|
455
|
+
fields: [{ name: 'attribution', label: 'Attribution', type: 'text' }],
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { quoteText: {}, attribution: {} } }], [blockCollection, other])).not.toThrow();
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -237,5 +237,48 @@ export function validateCollections(collections) {
|
|
|
237
237
|
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
238
|
}
|
|
239
239
|
validateVirtualFields(collection);
|
|
240
|
+
validateUploadLocations(collection);
|
|
240
241
|
}
|
|
241
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Enforce the `upload.location` shape for every upload-capable field. The
|
|
245
|
+
* value is a declarative storage-key scope handed to storage providers
|
|
246
|
+
* (`<location>/<uuid>-<filename>`), so it must be a clean POSIX-style
|
|
247
|
+
* segment path:
|
|
248
|
+
*
|
|
249
|
+
* - non-empty string, forward slashes only;
|
|
250
|
+
* - no leading / trailing / duplicate slashes;
|
|
251
|
+
* - segments of `A–Z a–z 0–9 . _ -` only (no spaces, no backslashes);
|
|
252
|
+
* - no `.` or `..` segments (path traversal).
|
|
253
|
+
*/
|
|
254
|
+
function validateUploadLocations(collection) {
|
|
255
|
+
walkFieldsWithPath(collection.fields, (field, fieldPath) => {
|
|
256
|
+
if (field.type !== 'file' && field.type !== 'image')
|
|
257
|
+
return;
|
|
258
|
+
const location = field.upload?.location;
|
|
259
|
+
if (location === undefined)
|
|
260
|
+
return;
|
|
261
|
+
const fail = (reason) => {
|
|
262
|
+
throw new Error(`Collection "${collection.path}" field "${fieldPath}" has invalid \`upload.location\` ` +
|
|
263
|
+
`${JSON.stringify(location)}: ${reason}`);
|
|
264
|
+
};
|
|
265
|
+
if (typeof location !== 'string' || location.length === 0) {
|
|
266
|
+
fail('must be a non-empty string.');
|
|
267
|
+
}
|
|
268
|
+
if (location.startsWith('/') || location.endsWith('/')) {
|
|
269
|
+
fail('must not start or end with a slash.');
|
|
270
|
+
}
|
|
271
|
+
const segments = location.split('/');
|
|
272
|
+
for (const segment of segments) {
|
|
273
|
+
if (segment.length === 0) {
|
|
274
|
+
fail('must not contain duplicate slashes.');
|
|
275
|
+
}
|
|
276
|
+
if (segment === '.' || segment === '..') {
|
|
277
|
+
fail('must not contain `.` or `..` segments.');
|
|
278
|
+
}
|
|
279
|
+
if (!/^[A-Za-z0-9._-]+$/.test(segment)) {
|
|
280
|
+
fail(`segment "${segment}" contains unsupported characters (allowed: A–Z a–z 0–9 . _ -).`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -452,4 +452,64 @@ describe('validateCollections', () => {
|
|
|
452
452
|
};
|
|
453
453
|
expect(() => validateCollections([collection])).toThrow(/listSearch.*virtual/s);
|
|
454
454
|
});
|
|
455
|
+
// upload.location — declarative storage-key scope shape.
|
|
456
|
+
const withLocation = (location) => ({
|
|
457
|
+
...baseCollection,
|
|
458
|
+
fields: [
|
|
459
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
460
|
+
{ name: 'cover', label: 'Cover', type: 'image', optional: true, upload: { location } },
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
it('accepts a single-segment upload.location', () => {
|
|
464
|
+
expect(() => validateCollections([withLocation('covers')])).not.toThrow();
|
|
465
|
+
});
|
|
466
|
+
it('accepts a nested upload.location', () => {
|
|
467
|
+
expect(() => validateCollections([withLocation('publications/covers')])).not.toThrow();
|
|
468
|
+
});
|
|
469
|
+
it('rejects an empty upload.location', () => {
|
|
470
|
+
expect(() => validateCollections([withLocation('')])).toThrow(/non-empty string/);
|
|
471
|
+
});
|
|
472
|
+
it('rejects a leading slash in upload.location', () => {
|
|
473
|
+
expect(() => validateCollections([withLocation('/covers')])).toThrow(/must not start or end with a slash/);
|
|
474
|
+
});
|
|
475
|
+
it('rejects a trailing slash in upload.location', () => {
|
|
476
|
+
expect(() => validateCollections([withLocation('covers/')])).toThrow(/must not start or end with a slash/);
|
|
477
|
+
});
|
|
478
|
+
it('rejects duplicate slashes in upload.location', () => {
|
|
479
|
+
expect(() => validateCollections([withLocation('a//b')])).toThrow(/must not contain duplicate slashes/);
|
|
480
|
+
});
|
|
481
|
+
it('rejects dot segments in upload.location', () => {
|
|
482
|
+
expect(() => validateCollections([withLocation('a/../b')])).toThrow(/must not contain `\.` or `\.\.` segments/);
|
|
483
|
+
});
|
|
484
|
+
it('rejects unsupported characters in upload.location', () => {
|
|
485
|
+
expect(() => validateCollections([withLocation('news attachments')])).toThrow(/unsupported characters/);
|
|
486
|
+
});
|
|
487
|
+
it('validates upload.location on fields nested inside blocks', () => {
|
|
488
|
+
const collection = {
|
|
489
|
+
...baseCollection,
|
|
490
|
+
fields: [
|
|
491
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
492
|
+
{
|
|
493
|
+
name: 'content',
|
|
494
|
+
label: 'Content',
|
|
495
|
+
type: 'blocks',
|
|
496
|
+
blocks: [
|
|
497
|
+
{
|
|
498
|
+
blockType: 'attachmentsBlock',
|
|
499
|
+
fields: [
|
|
500
|
+
{
|
|
501
|
+
name: 'file',
|
|
502
|
+
label: 'File',
|
|
503
|
+
type: 'file',
|
|
504
|
+
optional: true,
|
|
505
|
+
upload: { location: 'bad//path' },
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
};
|
|
513
|
+
expect(() => validateCollections([collection])).toThrow(/content\.file.*must not contain duplicate slashes/s);
|
|
514
|
+
});
|
|
455
515
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export * from './@types/index.js';
|
|
|
2
2
|
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
3
3
|
export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
|
|
4
4
|
export { resolveRoutes } from './config/routes.js';
|
|
5
|
-
export { validateAdminConfigs } from './config/validate-admin-configs.js';
|
|
5
|
+
export { validateAdminConfigs, validateBlockAdminConfigs, } from './config/validate-admin-configs.js';
|
|
6
6
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
7
7
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
8
8
|
export * from './defaults/default-values.js';
|
|
@@ -18,5 +18,6 @@ export * from './services/index.js';
|
|
|
18
18
|
export * from './storage/index.js';
|
|
19
19
|
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
20
20
|
export { formatTextValue, looksLikeISODate, type SlugifierFn, type SlugifyContext, slugify, } from './utils/slugify.js';
|
|
21
|
+
export { type FilenameSlugifierFn, type FilenameSlugifyContext, resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
|
|
21
22
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
22
23
|
export * from './workflow/index.js';
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ export * from './@types/index.js';
|
|
|
18
18
|
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
19
19
|
export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
|
|
20
20
|
export { resolveRoutes } from './config/routes.js';
|
|
21
|
-
export { validateAdminConfigs } from './config/validate-admin-configs.js';
|
|
21
|
+
export { validateAdminConfigs, validateBlockAdminConfigs, } from './config/validate-admin-configs.js';
|
|
22
22
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
23
23
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
24
24
|
export * from './defaults/default-values.js';
|
|
@@ -34,5 +34,6 @@ export * from './services/index.js';
|
|
|
34
34
|
export * from './storage/index.js';
|
|
35
35
|
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
36
36
|
export { formatTextValue, looksLikeISODate, slugify, } from './utils/slugify.js';
|
|
37
|
+
export { resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
|
|
37
38
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
38
39
|
export * from './workflow/index.js';
|
|
@@ -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);
|
|
@@ -109,6 +133,17 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
109
133
|
schema = textAreaSchema;
|
|
110
134
|
break;
|
|
111
135
|
}
|
|
136
|
+
case 'code': {
|
|
137
|
+
let codeSchema = z.string();
|
|
138
|
+
if (field.validation?.minLength) {
|
|
139
|
+
codeSchema = codeSchema.min(field.validation.minLength);
|
|
140
|
+
}
|
|
141
|
+
if (field.validation?.maxLength) {
|
|
142
|
+
codeSchema = codeSchema.max(field.validation.maxLength);
|
|
143
|
+
}
|
|
144
|
+
schema = codeSchema;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
112
147
|
case 'integer':
|
|
113
148
|
schema = z.number().int();
|
|
114
149
|
break;
|