@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.
- package/dist/@types/admin-types.d.ts +74 -2
- package/dist/@types/admin-types.js +29 -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/validate-admin-configs.d.ts +17 -1
- package/dist/config/validate-admin-configs.js +84 -11
- package/dist/config/validate-admin-configs.test.node.js +114 -1
- 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 +11 -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/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 +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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
147
|
+
fail(`${optionName}.field must be a non-empty string.`);
|
|
77
148
|
}
|
|
78
149
|
if (!topLevelFieldNames.has(field) && !documentColumns.has(field)) {
|
|
79
|
-
fail(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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/
|
|
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' },
|