@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.
Files changed (39) hide show
  1. package/dist/@types/admin-types.d.ts +92 -3
  2. package/dist/@types/admin-types.js +31 -0
  3. package/dist/@types/collection-types.d.ts +29 -7
  4. package/dist/@types/db-types.d.ts +7 -7
  5. package/dist/@types/field-data-types.d.ts +1 -0
  6. package/dist/@types/field-types.d.ts +36 -1
  7. package/dist/@types/site-config.d.ts +28 -1
  8. package/dist/@types/storage-types.d.ts +14 -1
  9. package/dist/codegen/fixtures/all-fields.d.ts +23 -0
  10. package/dist/codegen/fixtures/all-fields.expected.d.ts +4 -0
  11. package/dist/codegen/fixtures/all-fields.js +2 -0
  12. package/dist/codegen/index.js +2 -0
  13. package/dist/config/config.js +2 -1
  14. package/dist/config/routes.test.node.js +1 -7
  15. package/dist/config/validate-admin-configs.d.ts +22 -3
  16. package/dist/config/validate-admin-configs.js +157 -19
  17. package/dist/config/validate-admin-configs.test.node.js +205 -2
  18. package/dist/config/validate-collections.js +43 -0
  19. package/dist/config/validate-collections.test.node.js +60 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.js +2 -1
  22. package/dist/schemas/zod/builder.js +37 -2
  23. package/dist/schemas/zod/builder.test.node.js +72 -0
  24. package/dist/services/build-search-document.js +5 -1
  25. package/dist/services/document-to-markdown.js +10 -0
  26. package/dist/services/field-upload.d.ts +7 -0
  27. package/dist/services/field-upload.js +15 -13
  28. package/dist/services/field-upload.test.node.js +1 -1
  29. package/dist/services/populate.d.ts +1 -1
  30. package/dist/services/populate.js +1 -1
  31. package/dist/storage/collection-fingerprint.js +9 -0
  32. package/dist/storage/collection-fingerprint.test.node.js +1 -4
  33. package/dist/storage/field-store-map.js +1 -0
  34. package/dist/storage/field-store-map.test.node.js +1 -0
  35. package/dist/utils/slugify-filename.d.ts +50 -0
  36. package/dist/utils/slugify-filename.js +36 -0
  37. package/dist/utils/slugify-filename.test.node.d.ts +8 -0
  38. package/dist/utils/slugify-filename.test.node.js +50 -0
  39. 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` matches a top-level
25
- * schema field name.
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 this spec verbatim
66
- // (host list server fn → parseSort), so a bad field name would silently
67
- // fall back to `created_at desc` at request time — fail loudly at boot
68
- // instead.
69
- if (admin.defaultSort != null) {
70
- const { field, direction } = admin.defaultSort;
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(`defaultSort is not allowed on an orderable collection — manual ordering owns the default sort (order_key asc).`);
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(`defaultSort.field must be a non-empty string.`);
215
+ fail(`${optionName}.field must be a non-empty string.`);
77
216
  }
78
217
  if (!topLevelFieldNames.has(field) && !documentColumns.has(field)) {
79
- fail(`defaultSort.field "${String(field)}" is not a top-level schema field or a document column (createdAt, updatedAt, path).`);
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(`defaultSort.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
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 match top-level schema field names.
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
- for (const key of Object.keys(admin.fields)) {
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 a top-level schema field/);
202
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/does not resolve to a field declaration/);
203
+ });
204
+ // Rule 6 — dotted schema-path keys. A dedicated collection with nested
205
+ // structure: files (array) → filesGroup (group) → leaf fields, plus a
206
+ // blocks field to prove traversal through blocks is rejected.
207
+ const nestedCollection = {
208
+ path: 'library',
209
+ labels: { singular: 'Item', plural: 'Items' },
210
+ useAsPath: 'title',
211
+ fields: [
212
+ { name: 'title', label: 'Title', type: 'text' },
213
+ {
214
+ name: 'files',
215
+ label: 'Files',
216
+ type: 'array',
217
+ fields: [
218
+ {
219
+ name: 'filesGroup',
220
+ type: 'group',
221
+ fields: [
222
+ { name: 'publicationFile', label: 'File', type: 'file' },
223
+ { name: 'notes', label: 'Notes', type: 'richText' },
224
+ ],
225
+ },
226
+ ],
227
+ },
228
+ {
229
+ name: 'sections',
230
+ label: 'Sections',
231
+ type: 'blocks',
232
+ blocks: [
233
+ {
234
+ blockType: 'proseBlock',
235
+ fields: [{ name: 'body', label: 'Body', type: 'richText' }],
236
+ },
237
+ ],
238
+ },
239
+ ],
240
+ };
241
+ const nestedAdmin = (fields) => ({
242
+ slug: 'library',
243
+ fields: fields,
244
+ });
245
+ it('accepts a dotted fields key through array and group declarations', () => {
246
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.notes': {} })], [nestedCollection])).not.toThrow();
247
+ });
248
+ it('rejects a dotted fields key whose leaf does not exist', () => {
249
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.missing': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
250
+ });
251
+ it('rejects a dotted fields key that walks through a value field', () => {
252
+ expect(() => validateAdminConfigs([nestedAdmin({ 'title.anything': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
253
+ });
254
+ it('rejects a fields key carrying an item index', () => {
255
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files[0].filesGroup.notes': {} })], [nestedCollection])).toThrow(/index-free schema paths/);
256
+ });
257
+ it('rejects a fields key that traverses a blocks field', () => {
258
+ expect(() => validateAdminConfigs([nestedAdmin({ 'sections.body': {} })], [nestedCollection])).toThrow(/blockAdmin/);
203
259
  });
204
260
  // Rule 7 — defaultSort sanity.
205
261
  it('accepts a defaultSort on a schema field', () => {
@@ -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
- schema = z.any().array();
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;