@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
@@ -9,6 +9,7 @@ import { resolveUploadHooks } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
10
  import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
11
11
  import { withLogContext } from '../lib/logger.js';
12
+ import { resolveUploadFilename } from '../utils/slugify-filename.js';
12
13
  import { createDocument } from './document-lifecycle/index.js';
13
14
  function isMimeTypeAllowed(mimeType, allowedTypes) {
14
15
  return allowedTypes.some((allowed) => {
@@ -21,17 +22,6 @@ function isMimeTypeAllowed(mimeType, allowedTypes) {
21
22
  return allowed === mimeType;
22
23
  });
23
24
  }
24
- function sanitiseFilename(filename) {
25
- const lastDot = filename.lastIndexOf('.');
26
- const ext = lastDot !== -1 ? filename.slice(lastDot).toLowerCase() : '';
27
- const base = lastDot !== -1 ? filename.slice(0, lastDot) : filename;
28
- const safe = base
29
- .toLowerCase()
30
- .replace(/[^a-z0-9._-]/g, '-')
31
- .replace(/-+/g, '-')
32
- .replace(/^-|-$/g, '');
33
- return `${safe || 'file'}${ext}`;
34
- }
35
25
  /**
36
26
  * Walk a field set (recursing into `group` / `array` / `blocks`) and
37
27
  * locate the `image | file` field with the given name. Returns the
@@ -73,7 +63,7 @@ function normalizeUploadHook(hook) {
73
63
  *
74
64
  * - return a string or `{ filename }` to substitute a new filename;
75
65
  * - return `{ storagePath }` to take full control of the storage key
76
- * (bypasses provider key derivation — no UUID prefix). When set
66
+ * (bypasses provider key derivation — no entropy suffix). When set
77
67
  * without `filename`, the filename defaults to the path's basename;
78
68
  * - return `{ error }` to short-circuit with `ERR_VALIDATION`;
79
69
  * - return `void` / `undefined` to leave current values unchanged.
@@ -182,7 +172,14 @@ export async function uploadField(ctx, params) {
182
172
  // was supplied at the upload entry we hand them an empty one
183
173
  // rather than throw — `assertActorCanPerform` is the auth gate
184
174
  // for whether the upload should run at all, not the hook layer.
185
- const sanitised = sanitiseFilename(originalFilename || 'upload');
175
+ // The filename is slugified first (installation
176
+ // `uploads.filenameSlugifier`, else the default) so hooks observe
177
+ // the storage-ready base name.
178
+ const sanitised = resolveUploadFilename(originalFilename || 'upload', ctx.filenameSlugifier, {
179
+ collectionPath,
180
+ fieldName,
181
+ mimeType,
182
+ });
186
183
  // Resolve the field's upload hooks once. The loader form
187
184
  // (`hooks: () => import('./media.hooks.js')`) keeps server-only hook
188
185
  // graphs out of the client bundle; the inline form returns as-is.
@@ -216,6 +213,11 @@ export async function uploadField(ctx, params) {
216
213
  mimeType,
217
214
  size: fileSize,
218
215
  collection: collectionPath,
216
+ // Declarative storage-key scope (`upload.location`). Providers use
217
+ // it in place of the collection default while keeping their own
218
+ // entropy + sanitisation. An explicit hook `storagePath` still wins
219
+ // (targetStoragePath is written verbatim, below).
220
+ ...(upload.location !== undefined && { location: upload.location }),
219
221
  ...(effectiveStoragePath !== undefined && { targetStoragePath: effectiveStoragePath }),
220
222
  });
221
223
  }
@@ -333,7 +333,7 @@ describe('uploadField service', () => {
333
333
  fileSize: 3,
334
334
  shouldCreateDocument: false,
335
335
  });
336
- // Explicit key is handed to the provider verbatim — no UUID prefix,
336
+ // Explicit key is handed to the provider verbatim — no entropy suffix,
337
337
  // no collection-derived key.
338
338
  expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
339
339
  targetStoragePath: 'publications/forru-0000447-0001-en.png',
@@ -25,7 +25,7 @@
25
25
  * documents. The guard is in place from day one so that the hook work
26
26
  * in Phase 4+ cannot reintroduce the problem.
27
27
  *
28
- * See docs/04-collections/02-relationships.md for the full design rationale.
28
+ * See docs/04-collections/03-relationships.md for the full design rationale.
29
29
  *
30
30
  * ---------------------------------------------------------------------
31
31
  * DSL summary
@@ -25,7 +25,7 @@
25
25
  * documents. The guard is in place from day one so that the hook work
26
26
  * in Phase 4+ cannot reintroduce the problem.
27
27
  *
28
- * See docs/04-collections/02-relationships.md for the full design rationale.
28
+ * See docs/04-collections/03-relationships.md for the full design rationale.
29
29
  *
30
30
  * ---------------------------------------------------------------------
31
31
  * DSL summary
@@ -62,6 +62,10 @@ function canonicalField(field) {
62
62
  return base;
63
63
  case 'text':
64
64
  case 'textArea':
65
+ // `code` folds in `validation` only — `language` / `languageField` are
66
+ // presentation hints for the admin editor and never affect the stored
67
+ // value's shape, so (like labels) they stay out of the fingerprint.
68
+ case 'code':
65
69
  case 'richText':
66
70
  case 'float':
67
71
  case 'integer':
@@ -86,6 +90,11 @@ function canonicalUpload(u) {
86
90
  out.mimeTypes = [...u.mimeTypes].sort();
87
91
  if (u.maxFileSize !== undefined)
88
92
  out.maxFileSize = u.maxFileSize;
93
+ // Storage-key scope: where new uploads land is part of the field's
94
+ // storage contract (existing objects never move on change, but the
95
+ // declaration itself is schema-shape).
96
+ if (u.location !== undefined)
97
+ out.location = u.location;
89
98
  if (u.sizes !== undefined) {
90
99
  out.sizes = u.sizes
91
100
  .map((s) => ({
@@ -28,6 +28,7 @@ export const fieldTypeToStore = {
28
28
  // Text store
29
29
  text: { storeType: 'text', valueColumn: 'value' },
30
30
  textArea: { storeType: 'text', valueColumn: 'value' },
31
+ code: { storeType: 'text', valueColumn: 'value' },
31
32
  select: { storeType: 'text', valueColumn: 'value' },
32
33
  // Numeric store
33
34
  integer: { storeType: 'numeric', valueColumn: 'value_integer' },
@@ -18,6 +18,7 @@ import { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, } from './fiel
18
18
  const VALUE_FIELD_TYPES = [
19
19
  'text',
20
20
  'textArea',
21
+ 'code',
21
22
  'select',
22
23
  'integer',
23
24
  'float',
@@ -0,0 +1,50 @@
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
+ * Context passed to a `FilenameSlugifierFn`.
10
+ *
11
+ * `collectionPath` - The collection receiving the upload. Lets an
12
+ * installation-supplied slugifier branch by collection
13
+ * if filename policies differ.
14
+ * `fieldName` - The upload field's name within the collection.
15
+ * `mimeType` - The uploaded file's MIME type.
16
+ */
17
+ export type FilenameSlugifyContext = {
18
+ collectionPath: string;
19
+ fieldName: string;
20
+ mimeType: string;
21
+ };
22
+ /**
23
+ * A pure function that converts an uploaded file's **base name** (extension
24
+ * already split off) into a storage-safe slug. The extension is handled by
25
+ * the framework — lowercased and reattached after slugification — so
26
+ * implementations only see and return the base name.
27
+ *
28
+ * Registered installation-wide via `ServerConfig.uploads.filenameSlugifier`;
29
+ * the parallel of `ServerConfig.slugifier` for document paths. Must be
30
+ * synchronous and side-effect free. Return value is sanitised again by the
31
+ * storage provider as a safety net, but implementations should stay within
32
+ * `A–Z a–z 0–9 . _ -` to keep the slug authoritative.
33
+ */
34
+ export type FilenameSlugifierFn = (basename: string, ctx: FilenameSlugifyContext) => string;
35
+ /**
36
+ * Default filename slugifier — lowercases, replaces unsafe characters with
37
+ * hyphens, collapses runs, trims leading/trailing hyphens, and falls back
38
+ * to `'file'` for empty results. Mirrors the storage providers' own
39
+ * sanitisation rules so the default round-trips them unchanged.
40
+ */
41
+ export declare const slugifyFilename: FilenameSlugifierFn;
42
+ /**
43
+ * Resolve an uploaded file's stored filename: split the extension, apply the
44
+ * installation slugifier (or the default) to the base name, and reattach the
45
+ * lowercased extension. This runs in `uploadField` **before** the
46
+ * `beforeStore` hook chain, so hooks observe (and may override) the
47
+ * already-slugified name — the same ordering the previous fixed sanitiser
48
+ * had.
49
+ */
50
+ export declare function resolveUploadFilename(originalFilename: string, slugifier: FilenameSlugifierFn | undefined, ctx: FilenameSlugifyContext): string;
@@ -0,0 +1,36 @@
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
+ * Default filename slugifier — lowercases, replaces unsafe characters with
10
+ * hyphens, collapses runs, trims leading/trailing hyphens, and falls back
11
+ * to `'file'` for empty results. Mirrors the storage providers' own
12
+ * sanitisation rules so the default round-trips them unchanged.
13
+ */
14
+ export const slugifyFilename = (basename) => {
15
+ const safe = basename
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9._-]/g, '-')
18
+ .replace(/-+/g, '-')
19
+ .replace(/^-|-$/g, '');
20
+ return safe || 'file';
21
+ };
22
+ /**
23
+ * Resolve an uploaded file's stored filename: split the extension, apply the
24
+ * installation slugifier (or the default) to the base name, and reattach the
25
+ * lowercased extension. This runs in `uploadField` **before** the
26
+ * `beforeStore` hook chain, so hooks observe (and may override) the
27
+ * already-slugified name — the same ordering the previous fixed sanitiser
28
+ * had.
29
+ */
30
+ export function resolveUploadFilename(originalFilename, slugifier, ctx) {
31
+ const lastDot = originalFilename.lastIndexOf('.');
32
+ const ext = lastDot > 0 ? originalFilename.slice(lastDot).toLowerCase() : '';
33
+ const base = lastDot > 0 ? originalFilename.slice(0, lastDot) : originalFilename;
34
+ const slug = (slugifier ?? slugifyFilename)(base, ctx);
35
+ return `${slug || 'file'}${ext}`;
36
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,50 @@
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
+ import { describe, expect, it } from 'vitest';
9
+ import { resolveUploadFilename, slugifyFilename } from './slugify-filename.js';
10
+ const ctx = { collectionPath: 'events', fieldName: 'attachment', mimeType: 'application/pdf' };
11
+ describe('slugifyFilename (default)', () => {
12
+ it('lowercases and hyphenates unsafe characters', () => {
13
+ expect(slugifyFilename('Meeting Agenda (Final)', ctx)).toBe('meeting-agenda-final');
14
+ });
15
+ it('collapses hyphen runs and trims leading/trailing hyphens', () => {
16
+ expect(slugifyFilename('--Board -- Notes--', ctx)).toBe('board-notes');
17
+ });
18
+ it('falls back to "file" for empty results', () => {
19
+ expect(slugifyFilename('***', ctx)).toBe('file');
20
+ });
21
+ });
22
+ describe('resolveUploadFilename', () => {
23
+ it('slugifies the base name and lowercases the extension', () => {
24
+ expect(resolveUploadFilename('Meeting Agenda.PDF', undefined, ctx)).toBe('meeting-agenda.pdf');
25
+ });
26
+ it('handles filenames without an extension', () => {
27
+ expect(resolveUploadFilename('README', undefined, ctx)).toBe('readme');
28
+ });
29
+ it('treats a leading dot as part of the base name, not an extension', () => {
30
+ expect(resolveUploadFilename('.gitignore', undefined, ctx)).toBe('.gitignore');
31
+ });
32
+ it('only splits at the last dot', () => {
33
+ expect(resolveUploadFilename('archive.tar.gz', undefined, ctx)).toBe('archive.tar.gz');
34
+ });
35
+ it('applies a custom slugifier to the base name only', () => {
36
+ const shouty = (base) => base.toUpperCase().replace(/[^A-Z0-9]/g, '_');
37
+ expect(resolveUploadFilename('Meeting Agenda.pdf', shouty, ctx)).toBe('MEETING_AGENDA.pdf');
38
+ });
39
+ it('passes the slugify context through to the custom slugifier', () => {
40
+ const seen = [];
41
+ resolveUploadFilename('photo.jpg', (base, c) => {
42
+ seen.push(c);
43
+ return base;
44
+ }, ctx);
45
+ expect(seen).toEqual([ctx]);
46
+ });
47
+ it('guards against a custom slugifier returning an empty string', () => {
48
+ expect(resolveUploadFilename('photo.jpg', () => '', ctx)).toBe('file.jpg');
49
+ });
50
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.0.0",
5
+ "version": "4.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -81,7 +81,7 @@
81
81
  "pino": "^10.3.1",
82
82
  "sharp": "^0.35.3",
83
83
  "zod": "^4.4.3",
84
- "@byline/auth": "4.0.0"
84
+ "@byline/auth": "4.2.0"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@biomejs/biome": "2.5.2",