@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.
Files changed (39) 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 +36 -3
  13. package/dist/codegen/index.test.node.js +20 -2
  14. package/dist/config/config.js +2 -1
  15. package/dist/config/validate-admin-configs.d.ts +17 -1
  16. package/dist/config/validate-admin-configs.js +84 -11
  17. package/dist/config/validate-admin-configs.test.node.js +114 -1
  18. package/dist/config/validate-collections.js +43 -0
  19. package/dist/config/validate-collections.test.node.js +60 -0
  20. package/dist/host/host-request-bridge.d.ts +62 -0
  21. package/dist/host/host-request-bridge.js +38 -0
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.js +3 -1
  24. package/dist/schemas/zod/builder.js +11 -0
  25. package/dist/services/build-search-document.js +5 -1
  26. package/dist/services/document-to-markdown.js +10 -0
  27. package/dist/services/field-upload.d.ts +7 -0
  28. package/dist/services/field-upload.js +15 -13
  29. package/dist/services/field-upload.test.node.js +1 -1
  30. package/dist/services/populate.d.ts +1 -1
  31. package/dist/services/populate.js +1 -1
  32. package/dist/storage/collection-fingerprint.js +9 -0
  33. package/dist/storage/field-store-map.js +1 -0
  34. package/dist/storage/field-store-map.test.node.js +1 -0
  35. package/dist/utils/slugify-filename.d.ts +50 -0
  36. package/dist/utils/slugify-filename.js +36 -0
  37. package/dist/utils/slugify-filename.test.node.d.ts +8 -0
  38. package/dist/utils/slugify-filename.test.node.js +50 -0
  39. package/package.json +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 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
  });
@@ -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
- /** 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