@byline/core 4.0.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 +36 -3
- package/dist/codegen/index.test.node.js +20 -2
- 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/host/host-request-bridge.d.ts +62 -0
- package/dist/host/host-request-bridge.js +38 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type { CollectionAdminConfig, CollectionDefinition } from '../@types/index.js';
|
|
8
|
+
import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition } from '../@types/index.js';
|
|
9
9
|
/**
|
|
10
10
|
* Validate every admin config in a configuration.
|
|
11
11
|
*
|
|
@@ -35,3 +35,19 @@ import type { CollectionAdminConfig, CollectionDefinition } from '../@types/inde
|
|
|
35
35
|
* necessarily wired up.
|
|
36
36
|
*/
|
|
37
37
|
export declare function validateAdminConfigs(admins: readonly CollectionAdminConfig[] | undefined, collections: readonly CollectionDefinition[]): void;
|
|
38
|
+
/**
|
|
39
|
+
* Validate every block admin config in a configuration.
|
|
40
|
+
*
|
|
41
|
+
* Enforced rules (per `ClientConfig.blockAdmin` entry):
|
|
42
|
+
* 1. Block pairing — `blockType` matches a block declared on at least one
|
|
43
|
+
* `type: 'blocks'` field across the registered collections (blocks have
|
|
44
|
+
* no global registry; the collections walk is the source of truth).
|
|
45
|
+
* 2. Uniqueness — no two entries share a `blockType`.
|
|
46
|
+
* 3. `fields` map sanity — every key matches a top-level field name of the
|
|
47
|
+
* block (when the same `blockType` appears in several collections, the
|
|
48
|
+
* union of their top-level field names is accepted).
|
|
49
|
+
*
|
|
50
|
+
* Throws a plain `Error` for the same reason `validateAdminConfigs` does —
|
|
51
|
+
* this runs at startup, before the logger is necessarily wired up.
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateBlockAdminConfigs(blockAdmins: readonly BlockAdminConfig[] | undefined, collections: readonly CollectionDefinition[]): void;
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The host-framework request bridge — the seam that makes the server-side
|
|
10
|
+
* client stack (`@byline/client/server`) host-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* Everything request-scoped in that stack bottoms out in three
|
|
13
|
+
* primitives: "which request am I in" (identity for per-request
|
|
14
|
+
* memoization), "read a cookie", and "write a cookie" (admin session
|
|
15
|
+
* refresh rotates tokens). A host adapter implements those three over its
|
|
16
|
+
* framework's runtime and registers the bridge once at server boot;
|
|
17
|
+
* `@byline/client/server` is written against the interface and never
|
|
18
|
+
* imports a framework.
|
|
19
|
+
*
|
|
20
|
+
* Registration follows the same pattern as the config singletons in
|
|
21
|
+
* `config/config.ts`: a `Symbol.for` slot on `globalThis`, so every copy
|
|
22
|
+
* of this module (Vite SSR can resolve workspace-linked packages through
|
|
23
|
+
* different module graphs) shares the same state. Server-only by nature —
|
|
24
|
+
* hosts register from their boot path (side-effect imports guarantee
|
|
25
|
+
* registration before any request is handled), and nothing in a browser
|
|
26
|
+
* graph should ever reach for it.
|
|
27
|
+
*/
|
|
28
|
+
export interface HostCookieSetOptions {
|
|
29
|
+
httpOnly?: boolean;
|
|
30
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
31
|
+
secure?: boolean;
|
|
32
|
+
path?: string;
|
|
33
|
+
maxAge?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface HostRequestBridge {
|
|
36
|
+
/**
|
|
37
|
+
* A stable identity object for the current HTTP request, or `undefined`
|
|
38
|
+
* when running outside a request (seed scripts, background jobs, unit
|
|
39
|
+
* tests). Used purely as a WeakMap key for per-request memoization —
|
|
40
|
+
* never inspected.
|
|
41
|
+
*/
|
|
42
|
+
getRequest(): object | undefined;
|
|
43
|
+
/** Read a request cookie. Returns `undefined` when not present. */
|
|
44
|
+
getCookie(name: string): string | undefined;
|
|
45
|
+
/** Write a response cookie (admin session refresh, preview toggles). */
|
|
46
|
+
setCookie(name: string, value: string, options?: HostCookieSetOptions): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Register the host adapter's bridge. Idempotent and last-write-wins —
|
|
50
|
+
* host adapters register from side-effect imports, which may evaluate
|
|
51
|
+
* more than once across module graphs.
|
|
52
|
+
*/
|
|
53
|
+
export declare function registerHostRequestBridge(bridge: HostRequestBridge): void;
|
|
54
|
+
/** The registered bridge, or `undefined` when no host has registered one. */
|
|
55
|
+
export declare function tryGetHostRequestBridge(): HostRequestBridge | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The registered bridge, throwing with setup guidance when absent. Cookie
|
|
58
|
+
* reads/writes require a host; scripts and tests that have no request
|
|
59
|
+
* should pass an explicit `requestContext` to `createBylineClient`
|
|
60
|
+
* instead of using the request-bound getters.
|
|
61
|
+
*/
|
|
62
|
+
export declare function getHostRequestBridge(): HostRequestBridge;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
const BYLINE_HOST_REQUEST_BRIDGE = Symbol.for('__byline_host_request_bridge__');
|
|
9
|
+
/**
|
|
10
|
+
* Register the host adapter's bridge. Idempotent and last-write-wins —
|
|
11
|
+
* host adapters register from side-effect imports, which may evaluate
|
|
12
|
+
* more than once across module graphs.
|
|
13
|
+
*/
|
|
14
|
+
export function registerHostRequestBridge(bridge) {
|
|
15
|
+
;
|
|
16
|
+
globalThis[BYLINE_HOST_REQUEST_BRIDGE] = bridge;
|
|
17
|
+
}
|
|
18
|
+
/** The registered bridge, or `undefined` when no host has registered one. */
|
|
19
|
+
export function tryGetHostRequestBridge() {
|
|
20
|
+
return globalThis[BYLINE_HOST_REQUEST_BRIDGE] ?? undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The registered bridge, throwing with setup guidance when absent. Cookie
|
|
24
|
+
* reads/writes require a host; scripts and tests that have no request
|
|
25
|
+
* should pass an explicit `requestContext` to `createBylineClient`
|
|
26
|
+
* instead of using the request-bound getters.
|
|
27
|
+
*/
|
|
28
|
+
export function getHostRequestBridge() {
|
|
29
|
+
const bridge = tryGetHostRequestBridge();
|
|
30
|
+
if (!bridge) {
|
|
31
|
+
throw new Error('No HostRequestBridge registered. A host adapter (e.g. ' +
|
|
32
|
+
'@byline/host-tanstack-start) must call registerHostRequestBridge() ' +
|
|
33
|
+
'at server boot before request-bound client getters can resolve ' +
|
|
34
|
+
'cookies. Scripts and tests should pass an explicit requestContext ' +
|
|
35
|
+
'to createBylineClient instead.');
|
|
36
|
+
}
|
|
37
|
+
return bridge;
|
|
38
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,11 @@ 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';
|
|
9
|
+
export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
9
10
|
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
10
11
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
11
12
|
export { type BylineLogger, getLogger } from './lib/logger.js';
|
|
@@ -17,5 +18,6 @@ export * from './services/index.js';
|
|
|
17
18
|
export * from './storage/index.js';
|
|
18
19
|
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
19
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';
|
|
20
22
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
21
23
|
export * from './workflow/index.js';
|
package/dist/index.js
CHANGED
|
@@ -18,10 +18,11 @@ 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';
|
|
25
|
+
export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
25
26
|
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
26
27
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
27
28
|
export { getLogger } from './lib/logger.js';
|
|
@@ -33,5 +34,6 @@ export * from './services/index.js';
|
|
|
33
34
|
export * from './storage/index.js';
|
|
34
35
|
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
35
36
|
export { formatTextValue, looksLikeISODate, slugify, } from './utils/slugify.js';
|
|
37
|
+
export { resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
|
|
36
38
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
37
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
|