@byline/core 4.1.0 → 4.2.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 (36) hide show
  1. package/dist/@types/admin-types.d.ts +74 -2
  2. package/dist/@types/admin-types.js +29 -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/validate-admin-configs.d.ts +17 -1
  15. package/dist/config/validate-admin-configs.js +84 -11
  16. package/dist/config/validate-admin-configs.test.node.js +114 -1
  17. package/dist/config/validate-collections.js +43 -0
  18. package/dist/config/validate-collections.test.node.js +60 -0
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.js +2 -1
  21. package/dist/schemas/zod/builder.js +11 -0
  22. package/dist/services/build-search-document.js +5 -1
  23. package/dist/services/document-to-markdown.js +10 -0
  24. package/dist/services/field-upload.d.ts +7 -0
  25. package/dist/services/field-upload.js +15 -13
  26. package/dist/services/field-upload.test.node.js +1 -1
  27. package/dist/services/populate.d.ts +1 -1
  28. package/dist/services/populate.js +1 -1
  29. package/dist/storage/collection-fingerprint.js +9 -0
  30. package/dist/storage/field-store-map.js +1 -0
  31. package/dist/storage/field-store-map.test.node.js +1 -0
  32. package/dist/utils/slugify-filename.d.ts +50 -0
  33. package/dist/utils/slugify-filename.js +36 -0
  34. package/dist/utils/slugify-filename.test.node.d.ts +8 -0
  35. package/dist/utils/slugify-filename.test.node.js +50 -0
  36. package/package.json +2 -2
@@ -44,6 +44,75 @@ export function validateAdminConfigs(admins, collections) {
44
44
  validateOne(admin, collectionsByPath);
45
45
  }
46
46
  }
47
+ /**
48
+ * Validate every block admin config in a configuration.
49
+ *
50
+ * Enforced rules (per `ClientConfig.blockAdmin` entry):
51
+ * 1. Block pairing — `blockType` matches a block declared on at least one
52
+ * `type: 'blocks'` field across the registered collections (blocks have
53
+ * no global registry; the collections walk is the source of truth).
54
+ * 2. Uniqueness — no two entries share a `blockType`.
55
+ * 3. `fields` map sanity — every key matches a top-level field name of the
56
+ * block (when the same `blockType` appears in several collections, the
57
+ * union of their top-level field names is accepted).
58
+ *
59
+ * Throws a plain `Error` for the same reason `validateAdminConfigs` does —
60
+ * this runs at startup, before the logger is necessarily wired up.
61
+ */
62
+ export function validateBlockAdminConfigs(blockAdmins, collections) {
63
+ if (blockAdmins == null || blockAdmins.length === 0)
64
+ return;
65
+ // Collect blockType → union of top-level field names across every
66
+ // declaration site (including blocks nested inside groups/arrays/blocks).
67
+ const blockFieldNames = new Map();
68
+ const walkBlock = (block) => {
69
+ let names = blockFieldNames.get(block.blockType);
70
+ if (names == null) {
71
+ names = new Set();
72
+ blockFieldNames.set(block.blockType, names);
73
+ }
74
+ for (const field of block.fields) {
75
+ if ('name' in field)
76
+ names.add(field.name);
77
+ }
78
+ walkFields(block.fields);
79
+ };
80
+ const walkFields = (fields) => {
81
+ for (const field of fields) {
82
+ if (field.type === 'blocks') {
83
+ for (const block of field.blocks)
84
+ walkBlock(block);
85
+ }
86
+ else if ('fields' in field && Array.isArray(field.fields)) {
87
+ walkFields(field.fields);
88
+ }
89
+ }
90
+ };
91
+ for (const collection of collections) {
92
+ walkFields(collection.fields);
93
+ }
94
+ const seen = new Set();
95
+ for (const entry of blockAdmins) {
96
+ // Rule 2 — uniqueness.
97
+ if (seen.has(entry.blockType)) {
98
+ throw new Error(`Block admin config "${entry.blockType}" is registered more than once in \`blockAdmin\`.`);
99
+ }
100
+ seen.add(entry.blockType);
101
+ // Rule 1 — block pairing.
102
+ const names = blockFieldNames.get(entry.blockType);
103
+ if (names == null) {
104
+ 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
+ }
106
+ // Rule 3 — `fields` keys must be top-level field names of the block.
107
+ if (entry.fields != null) {
108
+ for (const key of Object.keys(entry.fields)) {
109
+ if (!names.has(key)) {
110
+ throw new Error(`Block "${entry.blockType}": \`fields["${key}"]\` references a name that is not a top-level field of the block. Per-field overrides apply only to the block's top-level fields.`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
47
116
  function validateOne(admin, collectionsByPath) {
48
117
  // Rule 1 — slug pairing.
49
118
  const collection = collectionsByPath.get(admin.slug);
@@ -62,26 +131,30 @@ function validateOne(admin, collectionsByPath) {
62
131
  if ('name' in field)
63
132
  topLevelFieldNames.add(field.name);
64
133
  }
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;
134
+ // Rule 7 — defaultSort / itemViewSort sanity. The list read applies these
135
+ // specs verbatim (host list server fn → parseSort), so a bad field name
136
+ // would silently fall back to `created_at desc` at request time — fail
137
+ // loudly at boot instead. Both options share one rule set.
138
+ const validateSortSpec = (spec, optionName) => {
139
+ if (spec == null)
140
+ return;
141
+ const { field, direction } = spec;
71
142
  const documentColumns = new Set(['createdAt', 'updatedAt', 'path']);
72
143
  if (collection.orderable === true) {
73
- fail(`defaultSort is not allowed on an orderable collection — manual ordering owns the default sort (order_key asc).`);
144
+ fail(`${optionName} is not allowed on an orderable collection — manual ordering owns the sort (order_key asc).`);
74
145
  }
75
146
  if (typeof field !== 'string' || field.length === 0) {
76
- fail(`defaultSort.field must be a non-empty string.`);
147
+ fail(`${optionName}.field must be a non-empty string.`);
77
148
  }
78
149
  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).`);
150
+ fail(`${optionName}.field "${String(field)}" is not a top-level schema field or a document column (createdAt, updatedAt, path).`);
80
151
  }
81
152
  if (direction != null && direction !== 'asc' && direction !== 'desc') {
82
- fail(`defaultSort.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
153
+ fail(`${optionName}.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
83
154
  }
84
- }
155
+ };
156
+ validateSortSpec(admin.defaultSort, 'defaultSort');
157
+ validateSortSpec(admin.itemViewSort, 'itemViewSort');
85
158
  // Build primitive lookup tables.
86
159
  const tabSets = admin.tabSets ?? [];
87
160
  const rows = admin.rows ?? [];
@@ -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' },
@@ -238,6 +238,44 @@ describe('validateAdminConfigs', () => {
238
238
  };
239
239
  expect(() => validateAdminConfigs([admin], [orderableCollection])).toThrow(/not allowed on an orderable collection/);
240
240
  });
241
+ // Rule 7 — itemViewSort shares the defaultSort rule set.
242
+ it('accepts an itemViewSort on a schema field', () => {
243
+ const admin = {
244
+ ...baseAdmin,
245
+ itemViewSort: { field: 'title' },
246
+ };
247
+ expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
248
+ });
249
+ it('accepts independent defaultSort and itemViewSort', () => {
250
+ const admin = {
251
+ ...baseAdmin,
252
+ defaultSort: { field: 'updatedAt', direction: 'desc' },
253
+ itemViewSort: { field: 'title' },
254
+ };
255
+ expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
256
+ });
257
+ it('rejects an itemViewSort field that resolves to nothing', () => {
258
+ const admin = {
259
+ ...baseAdmin,
260
+ itemViewSort: { field: 'nonexistent' },
261
+ };
262
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/itemViewSort\.field "nonexistent"/);
263
+ });
264
+ it('rejects an itemViewSort direction outside asc/desc', () => {
265
+ const admin = {
266
+ ...baseAdmin,
267
+ itemViewSort: { field: 'title', direction: 'sideways' },
268
+ };
269
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/itemViewSort\.direction must be 'asc' or 'desc'/);
270
+ });
271
+ it('rejects an itemViewSort on an orderable collection', () => {
272
+ const orderableCollection = { ...collection, orderable: true };
273
+ const admin = {
274
+ ...baseAdmin,
275
+ itemViewSort: { field: 'title' },
276
+ };
277
+ expect(() => validateAdminConfigs([admin], [orderableCollection])).toThrow(/not allowed on an orderable collection/);
278
+ });
241
279
  // Layout omitted — bookkeeping skipped, primitive declarations still checked.
242
280
  it('skips placement bookkeeping when layout is omitted', () => {
243
281
  const admin = {
@@ -258,3 +296,78 @@ describe('validateAdminConfigs', () => {
258
296
  expect(() => validateAdminConfigs([admin], [collection])).toThrow(/collides with a schema field/);
259
297
  });
260
298
  });
299
+ describe('validateBlockAdminConfigs', () => {
300
+ const quoteBlock = {
301
+ blockType: 'quoteBlock',
302
+ fields: [
303
+ { name: 'quoteText', label: 'Quote', type: 'richText' },
304
+ { name: 'source', label: 'Source', type: 'text' },
305
+ ],
306
+ };
307
+ const heroBlock = {
308
+ blockType: 'heroBlock',
309
+ fields: [{ name: 'heading', label: 'Heading', type: 'text' }],
310
+ };
311
+ const blockCollection = {
312
+ path: 'pages',
313
+ labels: { singular: 'Page', plural: 'Pages' },
314
+ useAsPath: 'title',
315
+ fields: [
316
+ { name: 'title', label: 'Title', type: 'text' },
317
+ { name: 'content', label: 'Content', type: 'blocks', blocks: [quoteBlock] },
318
+ {
319
+ name: 'meta',
320
+ label: 'Meta',
321
+ type: 'group',
322
+ fields: [
323
+ // Blocks fields nested inside structure fields are still collected.
324
+ { name: 'panels', label: 'Panels', type: 'blocks', blocks: [heroBlock] },
325
+ ],
326
+ },
327
+ ],
328
+ };
329
+ it('accepts a valid block admin config (baseline)', () => {
330
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { quoteText: {} } }], [blockCollection])).not.toThrow();
331
+ });
332
+ it('is a no-op when blockAdmins is undefined or empty', () => {
333
+ expect(() => validateBlockAdminConfigs(undefined, [blockCollection])).not.toThrow();
334
+ expect(() => validateBlockAdminConfigs([], [blockCollection])).not.toThrow();
335
+ });
336
+ // Rule 1 — block pairing.
337
+ it('rejects an entry whose blockType matches no declared block', () => {
338
+ expect(() => validateBlockAdminConfigs([{ blockType: 'missingBlock' }], [blockCollection])).toThrow(/no matching block/);
339
+ });
340
+ it('finds blocks declared inside nested structure fields', () => {
341
+ expect(() => validateBlockAdminConfigs([{ blockType: 'heroBlock', fields: { heading: {} } }], [blockCollection])).not.toThrow();
342
+ });
343
+ // Rule 2 — uniqueness.
344
+ it('rejects duplicate blockType entries', () => {
345
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock' }, { blockType: 'quoteBlock' }], [blockCollection])).toThrow(/registered more than once/);
346
+ });
347
+ // Rule 3 — fields map sanity.
348
+ it('rejects a fields key that is not a top-level field of the block', () => {
349
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/not a top-level field of the block/);
350
+ });
351
+ it('accepts the union of field names when a blockType appears in several collections', () => {
352
+ const other = {
353
+ path: 'docs',
354
+ labels: { singular: 'Doc', plural: 'Docs' },
355
+ useAsPath: 'title',
356
+ fields: [
357
+ { name: 'title', label: 'Title', type: 'text' },
358
+ {
359
+ name: 'content',
360
+ label: 'Content',
361
+ type: 'blocks',
362
+ blocks: [
363
+ {
364
+ blockType: 'quoteBlock',
365
+ fields: [{ name: 'attribution', label: 'Attribution', type: 'text' }],
366
+ },
367
+ ],
368
+ },
369
+ ],
370
+ };
371
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { quoteText: {}, attribution: {} } }], [blockCollection, other])).not.toThrow();
372
+ });
373
+ });
@@ -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';
@@ -109,6 +109,17 @@ export const fieldToZodSchema = (field, strict = true) => {
109
109
  schema = textAreaSchema;
110
110
  break;
111
111
  }
112
+ case 'code': {
113
+ let codeSchema = z.string();
114
+ if (field.validation?.minLength) {
115
+ codeSchema = codeSchema.min(field.validation.minLength);
116
+ }
117
+ if (field.validation?.maxLength) {
118
+ codeSchema = codeSchema.max(field.validation.maxLength);
119
+ }
120
+ schema = codeSchema;
121
+ break;
122
+ }
112
123
  case 'integer':
113
124
  schema = z.number().int();
114
125
  break;
@@ -122,7 +122,11 @@ function withBoost(field, decl) {
122
122
  const boost = typeof decl === 'string' ? undefined : decl.boost;
123
123
  return boost != null ? { ...field, boost } : field;
124
124
  }
125
- /** Text-bearing scalar leaves collected when walking into a container field. */
125
+ /**
126
+ * Text-bearing scalar leaves collected when walking into a container field.
127
+ * `code` is deliberately excluded — source snippets are full-text noise
128
+ * (identifiers, punctuation) that would pollute the index.
129
+ */
126
130
  const TEXT_LEAF_TYPES = new Set(['text', 'textArea']);
127
131
  /**
128
132
  * Resolve a single `search.body` field to its searchable text. A scalar /
@@ -154,6 +154,16 @@ function serializeField(field, value, ctx, level) {
154
154
  // toggles (constrainedWidth, featured) and json/object is
155
155
  // machine-shaped — the export renders content, not configuration.
156
156
  return null;
157
+ case 'code': {
158
+ // Fenced code block. The info string uses the schema's static
159
+ // `language` hint when present; a sibling `languageField` selection is
160
+ // not resolvable here (this serializer sees one field at a time), so
161
+ // those fences render bare — still valid markdown.
162
+ const text = stringValue(value);
163
+ if (text == null)
164
+ return null;
165
+ return `\`\`\`${field.language ?? ''}\n${text}\n\`\`\``;
166
+ }
157
167
  default: {
158
168
  const text = stringValue(value);
159
169
  return text ? labelled(field, text) : null;
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { RequestContext } from '@byline/auth';
9
+ import { type FilenameSlugifierFn } from '../utils/slugify-filename.js';
9
10
  import type { CollectionDefinition, IDbAdapter, IStorageProvider, StoredFileLocation, StoredFileValue, UploadConfig } from '../@types/index.js';
10
11
  import type { BylineLogger } from '../lib/logger.js';
11
12
  import type { SlugifierFn } from '../utils/slugify.js';
@@ -67,6 +68,12 @@ export interface FieldUploadContext {
67
68
  defaultLocale: string;
68
69
  /** Optional installation slugifier, forwarded to the lifecycle context. */
69
70
  slugifier?: SlugifierFn;
71
+ /**
72
+ * Optional installation filename slugifier
73
+ * (`ServerConfig.uploads.filenameSlugifier`), applied to the uploaded base
74
+ * name before the `beforeStore` chain. Defaults to `slugifyFilename`.
75
+ */
76
+ filenameSlugifier?: FilenameSlugifierFn;
70
77
  /**
71
78
  * Request-scoped auth context. Forwarded to the internal
72
79
  * `DocumentLifecycleContext` when an upload creates a document, and
@@ -9,6 +9,7 @@ import { resolveUploadHooks } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
10
  import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
11
11
  import { withLogContext } from '../lib/logger.js';
12
+ import { resolveUploadFilename } from '../utils/slugify-filename.js';
12
13
  import { createDocument } from './document-lifecycle/index.js';
13
14
  function isMimeTypeAllowed(mimeType, allowedTypes) {
14
15
  return allowedTypes.some((allowed) => {
@@ -21,17 +22,6 @@ function isMimeTypeAllowed(mimeType, allowedTypes) {
21
22
  return allowed === mimeType;
22
23
  });
23
24
  }
24
- function sanitiseFilename(filename) {
25
- const lastDot = filename.lastIndexOf('.');
26
- const ext = lastDot !== -1 ? filename.slice(lastDot).toLowerCase() : '';
27
- const base = lastDot !== -1 ? filename.slice(0, lastDot) : filename;
28
- const safe = base
29
- .toLowerCase()
30
- .replace(/[^a-z0-9._-]/g, '-')
31
- .replace(/-+/g, '-')
32
- .replace(/^-|-$/g, '');
33
- return `${safe || 'file'}${ext}`;
34
- }
35
25
  /**
36
26
  * Walk a field set (recursing into `group` / `array` / `blocks`) and
37
27
  * locate the `image | file` field with the given name. Returns the
@@ -73,7 +63,7 @@ function normalizeUploadHook(hook) {
73
63
  *
74
64
  * - return a string or `{ filename }` to substitute a new filename;
75
65
  * - return `{ storagePath }` to take full control of the storage key
76
- * (bypasses provider key derivation — no UUID prefix). When set
66
+ * (bypasses provider key derivation — no entropy suffix). When set
77
67
  * without `filename`, the filename defaults to the path's basename;
78
68
  * - return `{ error }` to short-circuit with `ERR_VALIDATION`;
79
69
  * - return `void` / `undefined` to leave current values unchanged.
@@ -182,7 +172,14 @@ export async function uploadField(ctx, params) {
182
172
  // was supplied at the upload entry we hand them an empty one
183
173
  // rather than throw — `assertActorCanPerform` is the auth gate
184
174
  // for whether the upload should run at all, not the hook layer.
185
- const sanitised = sanitiseFilename(originalFilename || 'upload');
175
+ // The filename is slugified first (installation
176
+ // `uploads.filenameSlugifier`, else the default) so hooks observe
177
+ // the storage-ready base name.
178
+ const sanitised = resolveUploadFilename(originalFilename || 'upload', ctx.filenameSlugifier, {
179
+ collectionPath,
180
+ fieldName,
181
+ mimeType,
182
+ });
186
183
  // Resolve the field's upload hooks once. The loader form
187
184
  // (`hooks: () => import('./media.hooks.js')`) keeps server-only hook
188
185
  // graphs out of the client bundle; the inline form returns as-is.
@@ -216,6 +213,11 @@ export async function uploadField(ctx, params) {
216
213
  mimeType,
217
214
  size: fileSize,
218
215
  collection: collectionPath,
216
+ // Declarative storage-key scope (`upload.location`). Providers use
217
+ // it in place of the collection default while keeping their own
218
+ // entropy + sanitisation. An explicit hook `storagePath` still wins
219
+ // (targetStoragePath is written verbatim, below).
220
+ ...(upload.location !== undefined && { location: upload.location }),
219
221
  ...(effectiveStoragePath !== undefined && { targetStoragePath: effectiveStoragePath }),
220
222
  });
221
223
  }
@@ -333,7 +333,7 @@ describe('uploadField service', () => {
333
333
  fileSize: 3,
334
334
  shouldCreateDocument: false,
335
335
  });
336
- // Explicit key is handed to the provider verbatim — no UUID prefix,
336
+ // Explicit key is handed to the provider verbatim — no entropy suffix,
337
337
  // no collection-derived key.
338
338
  expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
339
339
  targetStoragePath: 'publications/forru-0000447-0001-en.png',
@@ -25,7 +25,7 @@
25
25
  * documents. The guard is in place from day one so that the hook work
26
26
  * in Phase 4+ cannot reintroduce the problem.
27
27
  *
28
- * See docs/04-collections/02-relationships.md for the full design rationale.
28
+ * See docs/04-collections/03-relationships.md for the full design rationale.
29
29
  *
30
30
  * ---------------------------------------------------------------------
31
31
  * DSL summary
@@ -25,7 +25,7 @@
25
25
  * documents. The guard is in place from day one so that the hook work
26
26
  * in Phase 4+ cannot reintroduce the problem.
27
27
  *
28
- * See docs/04-collections/02-relationships.md for the full design rationale.
28
+ * See docs/04-collections/03-relationships.md for the full design rationale.
29
29
  *
30
30
  * ---------------------------------------------------------------------
31
31
  * DSL summary
@@ -62,6 +62,10 @@ function canonicalField(field) {
62
62
  return base;
63
63
  case 'text':
64
64
  case 'textArea':
65
+ // `code` folds in `validation` only — `language` / `languageField` are
66
+ // presentation hints for the admin editor and never affect the stored
67
+ // value's shape, so (like labels) they stay out of the fingerprint.
68
+ case 'code':
65
69
  case 'richText':
66
70
  case 'float':
67
71
  case 'integer':
@@ -86,6 +90,11 @@ function canonicalUpload(u) {
86
90
  out.mimeTypes = [...u.mimeTypes].sort();
87
91
  if (u.maxFileSize !== undefined)
88
92
  out.maxFileSize = u.maxFileSize;
93
+ // Storage-key scope: where new uploads land is part of the field's
94
+ // storage contract (existing objects never move on change, but the
95
+ // declaration itself is schema-shape).
96
+ if (u.location !== undefined)
97
+ out.location = u.location;
89
98
  if (u.sizes !== undefined) {
90
99
  out.sizes = u.sizes
91
100
  .map((s) => ({
@@ -28,6 +28,7 @@ export const fieldTypeToStore = {
28
28
  // Text store
29
29
  text: { storeType: 'text', valueColumn: 'value' },
30
30
  textArea: { storeType: 'text', valueColumn: 'value' },
31
+ code: { storeType: 'text', valueColumn: 'value' },
31
32
  select: { storeType: 'text', valueColumn: 'value' },
32
33
  // Numeric store
33
34
  integer: { storeType: 'numeric', valueColumn: 'value_integer' },
@@ -18,6 +18,7 @@ import { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, } from './fiel
18
18
  const VALUE_FIELD_TYPES = [
19
19
  'text',
20
20
  'textArea',
21
+ 'code',
21
22
  'select',
22
23
  'integer',
23
24
  'float',