@byline/core 3.13.3 → 3.15.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 (34) hide show
  1. package/dist/@types/admin-types.d.ts +45 -7
  2. package/dist/@types/collection-types.d.ts +32 -4
  3. package/dist/@types/field-types.d.ts +43 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/@types/search-types.d.ts +275 -0
  7. package/dist/@types/search-types.js +8 -0
  8. package/dist/@types/site-config.d.ts +31 -1
  9. package/dist/auth/assert-actor-can-perform.test.node.js +9 -1
  10. package/dist/auth/register-collection-abilities.d.ts +8 -5
  11. package/dist/auth/register-collection-abilities.js +14 -4
  12. package/dist/auth/register-collection-abilities.test.node.js +11 -7
  13. package/dist/config/config.d.ts +12 -1
  14. package/dist/config/config.js +11 -0
  15. package/dist/config/resolve-item-view-columns.test.node.d.ts +8 -0
  16. package/dist/config/resolve-item-view-columns.test.node.js +30 -0
  17. package/dist/core.js +7 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/schemas/zod/builder.js +16 -5
  21. package/dist/services/build-search-document.d.ts +46 -0
  22. package/dist/services/build-search-document.js +220 -0
  23. package/dist/services/build-search-document.test.node.d.ts +8 -0
  24. package/dist/services/build-search-document.test.node.js +164 -0
  25. package/dist/services/index.d.ts +3 -1
  26. package/dist/services/index.js +3 -1
  27. package/dist/services/populate.d.ts +6 -0
  28. package/dist/services/populate.js +19 -1
  29. package/dist/services/relation-projection.d.ts +4 -15
  30. package/dist/services/relation-projection.js +19 -6
  31. package/dist/services/validate-search-config.d.ts +28 -0
  32. package/dist/services/validate-search-config.js +32 -0
  33. package/dist/storage/collection-fingerprint.test.node.js +1 -1
  34. package/package.json +2 -2
@@ -42,6 +42,7 @@ describe('registerCollectionAbilities', () => {
42
42
  'collections.pages.delete',
43
43
  'collections.pages.publish',
44
44
  'collections.pages.changeStatus',
45
+ 'collections.pages.reindex',
45
46
  ]);
46
47
  });
47
48
  it('places every ability under the same `collections.<path>` group', () => {
@@ -49,7 +50,7 @@ describe('registerCollectionAbilities', () => {
49
50
  registerCollectionAbilities(registry, pageCollection());
50
51
  const buckets = registry.byGroup();
51
52
  expect(buckets.size).toBe(1);
52
- expect(buckets.get('collections.pages')?.length).toBe(6);
53
+ expect(buckets.get('collections.pages')?.length).toBe(7);
53
54
  });
54
55
  it('derives labels from the collection singular/plural labels', () => {
55
56
  const registry = new AbilityRegistry();
@@ -60,6 +61,7 @@ describe('registerCollectionAbilities', () => {
60
61
  expect(registry.get('collections.pages.delete')?.label).toBe('Delete Page');
61
62
  expect(registry.get('collections.pages.publish')?.label).toBe('Publish Pages');
62
63
  expect(registry.get('collections.pages.changeStatus')?.label).toBe('Change status of Pages');
64
+ expect(registry.get('collections.pages.reindex')?.label).toBe('Reindex Pages search');
63
65
  });
64
66
  it('tags every ability with source: "collection"', () => {
65
67
  const registry = new AbilityRegistry();
@@ -68,7 +70,7 @@ describe('registerCollectionAbilities', () => {
68
70
  expect(descriptor.source).toBe('collection');
69
71
  }
70
72
  });
71
- it('registers the same six-ability shape regardless of workflow complexity', () => {
73
+ it('registers the same seven-ability shape regardless of workflow complexity', () => {
72
74
  const registry = new AbilityRegistry();
73
75
  registerCollectionAbilities(registry, newsCollection());
74
76
  expect(registry.list().map((d) => d.key)).toEqual([
@@ -78,14 +80,15 @@ describe('registerCollectionAbilities', () => {
78
80
  'collections.news.delete',
79
81
  'collections.news.publish',
80
82
  'collections.news.changeStatus',
83
+ 'collections.news.reindex',
81
84
  ]);
82
85
  });
83
- it('is idempotent — calling twice leaves the registry with the same six entries', () => {
86
+ it('is idempotent — calling twice leaves the registry with the same seven entries', () => {
84
87
  const registry = new AbilityRegistry();
85
88
  const collection = pageCollection();
86
89
  registerCollectionAbilities(registry, collection);
87
90
  registerCollectionAbilities(registry, collection);
88
- expect(registry.size).toBe(6);
91
+ expect(registry.size).toBe(7);
89
92
  });
90
93
  it('keeps multiple collections isolated in distinct groups', () => {
91
94
  const registry = new AbilityRegistry();
@@ -93,9 +96,9 @@ describe('registerCollectionAbilities', () => {
93
96
  registerCollectionAbilities(registry, newsCollection());
94
97
  const buckets = registry.byGroup();
95
98
  expect(buckets.size).toBe(2);
96
- expect(buckets.get('collections.pages')?.length).toBe(6);
97
- expect(buckets.get('collections.news')?.length).toBe(6);
98
- expect(registry.size).toBe(12);
99
+ expect(buckets.get('collections.pages')?.length).toBe(7);
100
+ expect(buckets.get('collections.news')?.length).toBe(7);
101
+ expect(registry.size).toBe(14);
99
102
  });
100
103
  });
101
104
  describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
@@ -107,6 +110,7 @@ describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
107
110
  'delete',
108
111
  'publish',
109
112
  'changeStatus',
113
+ 'reindex',
110
114
  ]);
111
115
  });
112
116
  it('collectionAbilityKey composes a flat dotted key', () => {
@@ -1,4 +1,4 @@
1
- import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ServerConfig } from '../@types/index.js';
1
+ import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ColumnDefinition, ServerConfig } from '../@types/index.js';
2
2
  /**
3
3
  * Resolve a collection definition by `path`. Returns `null` either when
4
4
  * no config has been registered (e.g. unit tests, isolated tooling) or
@@ -12,6 +12,17 @@ import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ServerC
12
12
  */
13
13
  export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
14
14
  export declare const getCollectionAdminConfig: (slug: string) => CollectionAdminConfig | null;
15
+ /**
16
+ * Resolve a collection's item-row/tile columns — the per-collection projection
17
+ * + presentation contract used by the relation picker, relation/`hasMany`
18
+ * tiles, and (planned) search-result rows.
19
+ *
20
+ * Prefers the canonical {@link CollectionAdminConfig.itemView}, falling back to
21
+ * the deprecated `picker` alias. Always read item-view columns through this
22
+ * helper rather than touching `config.picker` directly, so the alias keeps
23
+ * working until it is removed.
24
+ */
25
+ export declare const resolveItemViewColumns: (config: CollectionAdminConfig | null | undefined) => ColumnDefinition[] | undefined;
15
26
  export declare function defineClientConfig(config: ClientConfig): void;
16
27
  export declare function defineServerConfig(config: ServerConfig): void;
17
28
  export declare function getClientConfig(): ClientConfig;
@@ -54,6 +54,17 @@ export const getCollectionAdminConfig = (slug) => {
54
54
  return null;
55
55
  return clientConfig.admin?.find((admin) => admin.slug === slug) ?? null;
56
56
  };
57
+ /**
58
+ * Resolve a collection's item-row/tile columns — the per-collection projection
59
+ * + presentation contract used by the relation picker, relation/`hasMany`
60
+ * tiles, and (planned) search-result rows.
61
+ *
62
+ * Prefers the canonical {@link CollectionAdminConfig.itemView}, falling back to
63
+ * the deprecated `picker` alias. Always read item-view columns through this
64
+ * helper rather than touching `config.picker` directly, so the alias keeps
65
+ * working until it is removed.
66
+ */
67
+ export const resolveItemViewColumns = (config) => config?.itemView ?? config?.picker;
57
68
  export function defineClientConfig(config) {
58
69
  validateCollections(config.collections);
59
70
  validateAdminConfigs(config.admin, config.collections);
@@ -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,30 @@
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 { resolveItemViewColumns } from './config.js';
10
+ const itemViewCols = [{ fieldName: 'title', label: 'Title' }];
11
+ const pickerCols = [{ fieldName: 'name', label: 'Name' }];
12
+ const cfg = (over) => ({ slug: 'x', ...over });
13
+ describe('resolveItemViewColumns', () => {
14
+ it('returns itemView when present', () => {
15
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols }))).toBe(itemViewCols);
16
+ });
17
+ it('falls back to the deprecated picker alias', () => {
18
+ expect(resolveItemViewColumns(cfg({ picker: pickerCols }))).toBe(pickerCols);
19
+ });
20
+ it('prefers itemView over picker when both are present', () => {
21
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols, picker: pickerCols }))).toBe(itemViewCols);
22
+ });
23
+ it('returns undefined when neither is set', () => {
24
+ expect(resolveItemViewColumns(cfg({}))).toBeUndefined();
25
+ });
26
+ it('tolerates null / undefined config', () => {
27
+ expect(resolveItemViewColumns(null)).toBeUndefined();
28
+ expect(resolveItemViewColumns(undefined)).toBeUndefined();
29
+ });
30
+ });
package/dist/core.js CHANGED
@@ -14,6 +14,7 @@ import { ensureCollections } from './services/collection-bootstrap.js';
14
14
  import { discoverCounterGroups } from './services/discover-counter-groups.js';
15
15
  import { validateTranslations } from './services/i18n-validator.js';
16
16
  import { validateRichTextFieldFlags } from './services/richtext-populate.js';
17
+ import { validateSearchConfig } from './services/validate-search-config.js';
17
18
  /**
18
19
  * Initialize Byline CMS core services via the typed registry.
19
20
  *
@@ -46,6 +47,12 @@ export const initBylineCore = async (config, pinoLogger) => {
46
47
  populate: config.fields?.richText?.populate != null,
47
48
  embed: config.fields?.richText?.embed != null,
48
49
  });
50
+ // Validate search configuration: a collection that opts into search must
51
+ // have a SearchProvider registered, otherwise indexing / client.search()
52
+ // would silently no-op. Fail-fast at boot, same posture as richText above.
53
+ validateSearchConfig(composed.collections, {
54
+ provider: config.search != null,
55
+ });
49
56
  // Validate the admin i18n translation registry against the configured
50
57
  // interface locale set. Throws on structural errors (missing bundle for
51
58
  // a declared locale, defaultLocale outside the permitted set, …); soft
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from './@types/index.js';
2
2
  export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
3
- export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
3
+ export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
4
4
  export { resolveRoutes } from './config/routes.js';
5
5
  export { validateAdminConfigs } from './config/validate-admin-configs.js';
6
6
  export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // ---------------------------------------------------------------------------
17
17
  export * from './@types/index.js';
18
18
  export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
19
- export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
19
+ export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
20
20
  export { resolveRoutes } from './config/routes.js';
21
21
  export { validateAdminConfigs } from './config/validate-admin-configs.js';
22
22
  export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
@@ -155,7 +155,7 @@ export const fieldToZodSchema = (field, strict = true) => {
155
155
  // (recursive) and is not constrained here.
156
156
  schema = z.any();
157
157
  break;
158
- case 'relation':
158
+ case 'relation': {
159
159
  // Relation values are `RelatedDocumentValue` objects:
160
160
  // { targetDocumentId, targetCollectionId,
161
161
  // relationshipType?, cascadeDelete? }
@@ -165,15 +165,26 @@ export const fieldToZodSchema = (field, strict = true) => {
165
165
  //
166
166
  // Populated responses (depth > 0) skip this schema in the route layer
167
167
  // because the tree then contains nested documents, not bare refs.
168
- schema = z
169
- .object({
168
+ const relationValue = z.object({
170
169
  targetDocumentId: z.string(),
171
170
  targetCollectionId: z.string(),
172
171
  relationshipType: z.string().optional(),
173
172
  cascadeDelete: z.boolean().optional(),
174
- })
175
- .nullable();
173
+ });
174
+ if (field.hasMany) {
175
+ // Ordered list of relation values; `minItems` / `maxItems` bound it.
176
+ let arr = z.array(relationValue);
177
+ if (typeof field.minItems === 'number')
178
+ arr = arr.min(field.minItems);
179
+ if (typeof field.maxItems === 'number')
180
+ arr = arr.max(field.maxItems);
181
+ schema = arr;
182
+ }
183
+ else {
184
+ schema = relationValue.nullable();
185
+ }
176
186
  break;
187
+ }
177
188
  default:
178
189
  schema = z.string();
179
190
  }
@@ -0,0 +1,46 @@
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 type { CollectionDefinition, RichTextToTextFn, SearchDocument } from '../@types/index.js';
9
+ /** A locale-resolved document fed to the assembler — one locale's view. */
10
+ export interface SearchSourceDocument {
11
+ /** Stable document id (shared across versions and locales). */
12
+ documentId: string;
13
+ /** Content locale this view represents. */
14
+ locale: string;
15
+ /** Lifecycle status of the indexed version. */
16
+ status: string;
17
+ /** URL path, or null when the collection has none. */
18
+ path?: string | null;
19
+ /**
20
+ * Locale-resolved, camelCase field data (the `ClientDocument.fields`
21
+ * shape). Relation fields named in `search.facets` must be populated.
22
+ */
23
+ fields: Record<string, any>;
24
+ /** Timestamp of the indexed version. */
25
+ updatedAt?: Date | string;
26
+ }
27
+ export interface BuildSearchDocumentOptions {
28
+ /**
29
+ * Rich-text plain-text extractor (`ServerConfig.fields.richText.toText`).
30
+ * Required for `richText` fields named in `search.body`; without it those
31
+ * fields are skipped.
32
+ */
33
+ richTextToText?: RichTextToTextFn;
34
+ /**
35
+ * Resolve a target collection definition by path — used to find a facet
36
+ * target's identity field (the term) and `counter` field (the id).
37
+ */
38
+ resolveTargetDefinition?: (collectionPath: string) => CollectionDefinition | null;
39
+ /** Content locale, for defensive locale-envelope resolution. */
40
+ locale?: string;
41
+ }
42
+ /**
43
+ * Assemble one type-enriched `SearchDocument` from a locale-resolved
44
+ * document and its collection's role-based `search` config.
45
+ */
46
+ export declare function buildSearchDocument(doc: SearchSourceDocument, definition: CollectionDefinition, options?: BuildSearchDocumentOptions): SearchDocument;
@@ -0,0 +1,220 @@
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
+ * `buildSearchDocument` — the document-grain assembler for the
10
+ * `SearchProvider` seam. Walks a collection's role-based `search` config
11
+ * against one locale-resolved document and emits a single, type-enriched
12
+ * `SearchDocument` for a driver to index. See
13
+ * `docs/05-reading-and-delivery/07-search.md`.
14
+ *
15
+ * Role-based and explicit: only the fields named in `search.{body,facets,
16
+ * filters}` are projected — nothing is auto-pulled, so unindexed content
17
+ * never leaks into the index. Core derives each field's `SearchFieldType`
18
+ * from the schema (the "type enrichment") so a driver can map it onto its
19
+ * own index without re-inspecting the collection definition.
20
+ *
21
+ * Pure and synchronous, like `documentToMarkdown`: the rich-text plain-text
22
+ * extractor is the editor-agnostic `toText` seam passed via options, and
23
+ * relation targets are resolved through a caller-supplied definition
24
+ * resolver — no globals, no DB reads. The caller is responsible for handing
25
+ * in a document whose `facets` relation fields are already populated (depth
26
+ * 1) with the target's identity + counter fields.
27
+ *
28
+ * v1 scope: `search.{body,facets,filters}` name **top-level** fields. Deep
29
+ * paths into blocks / arrays are a follow-up.
30
+ */
31
+ import { resolveIdentityField } from './populate.js';
32
+ /**
33
+ * Assemble one type-enriched `SearchDocument` from a locale-resolved
34
+ * document and its collection's role-based `search` config.
35
+ */
36
+ export function buildSearchDocument(doc, definition, options = {}) {
37
+ const locale = options.locale ?? doc.locale;
38
+ const search = definition.search ?? {};
39
+ const fieldsData = doc.fields ?? {};
40
+ const title = stringValue(resolveLocalized(fieldsData[resolveIdentityField(definition) ?? ''], locale)) ?? '';
41
+ const zones = search.zones != null && search.zones.length > 0 ? search.zones : [definition.path];
42
+ const fields = [];
43
+ // --- body: searchable text -------------------------------------------------
44
+ for (const decl of search.body ?? []) {
45
+ const name = declName(decl);
46
+ const field = definition.fields.find((f) => f.name === name);
47
+ if (field == null)
48
+ continue;
49
+ let value;
50
+ if (field.type === 'richText') {
51
+ value = options.richTextToText
52
+ ? nonEmpty(options.richTextToText({
53
+ value: resolveLocalized(fieldsData[name], locale),
54
+ fieldPath: name,
55
+ collectionPath: definition.path,
56
+ }))
57
+ : null;
58
+ }
59
+ else {
60
+ value = stringValue(resolveLocalized(fieldsData[name], locale));
61
+ }
62
+ if (value != null) {
63
+ fields.push(withBoost({ name, type: 'text', role: 'body', value }, decl));
64
+ }
65
+ }
66
+ // --- facets: controlled-vocabulary references ------------------------------
67
+ for (const decl of search.facets ?? []) {
68
+ const name = declName(decl);
69
+ const field = definition.fields.find((f) => f.name === name);
70
+ if (field == null || field.type !== 'relation')
71
+ continue;
72
+ const targetPath = field.targetCollection;
73
+ const targetDef = targetPath ? (options.resolveTargetDefinition?.(targetPath) ?? null) : null;
74
+ const termField = targetDef ? resolveIdentityField(targetDef) : undefined;
75
+ const idField = targetDef?.fields.find((f) => f.type === 'counter')?.name;
76
+ const facetValues = toEnvelopeArray(fieldsData[name])
77
+ .map((env) => extractFacetValue(env, termField, idField, locale))
78
+ .filter((v) => v != null);
79
+ if (facetValues.length > 0) {
80
+ fields.push(withBoost({ name, type: 'facet', role: 'facet', value: facetValues }, decl));
81
+ }
82
+ }
83
+ // --- filters: scalar projections for filtering / sorting -------------------
84
+ for (const name of search.filters ?? []) {
85
+ const field = definition.fields.find((f) => f.name === name);
86
+ if (field == null)
87
+ continue;
88
+ const type = filterType(field.type);
89
+ const value = coerceFilterValue(resolveLocalized(fieldsData[name], locale), type);
90
+ if (value != null) {
91
+ fields.push({ name, type, role: 'filter', value });
92
+ }
93
+ }
94
+ return {
95
+ collectionPath: definition.path,
96
+ documentId: doc.documentId,
97
+ locale,
98
+ status: doc.status,
99
+ zones,
100
+ title,
101
+ path: doc.path ?? null,
102
+ fields,
103
+ updatedAt: dateValue(doc.updatedAt) ?? new Date().toISOString(),
104
+ };
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Internals
108
+ // ---------------------------------------------------------------------------
109
+ function declName(decl) {
110
+ return typeof decl === 'string' ? decl : decl.field;
111
+ }
112
+ function withBoost(field, decl) {
113
+ const boost = typeof decl === 'string' ? undefined : decl.boost;
114
+ return boost != null ? { ...field, boost } : field;
115
+ }
116
+ /** A populated single relation or array of relations → array of envelopes. */
117
+ function toEnvelopeArray(value) {
118
+ if (Array.isArray(value))
119
+ return value.filter((v) => v != null);
120
+ if (value != null && typeof value === 'object')
121
+ return [value];
122
+ return [];
123
+ }
124
+ function extractFacetValue(envelope, termField, idField, locale) {
125
+ const target = asRecord(envelope.document);
126
+ const targetFields = asRecord(target.fields);
127
+ const term = termField ? stringValue(resolveLocalized(targetFields[termField], locale)) : null;
128
+ if (term == null)
129
+ return null;
130
+ // Prefer the target's stable counter id (the aggregator's reporting key);
131
+ // fall back to its document id when the vocabulary has no counter field.
132
+ const rawId = idField != null ? targetFields[idField] : undefined;
133
+ const id = typeof rawId === 'number' || typeof rawId === 'string'
134
+ ? rawId
135
+ : (stringValue(target.documentId ?? target.id) ?? term);
136
+ return { id, term };
137
+ }
138
+ /** Map a collection field type to the filter-side `SearchFieldType`. */
139
+ function filterType(fieldType) {
140
+ switch (fieldType) {
141
+ case 'integer':
142
+ case 'counter':
143
+ return 'integer';
144
+ case 'float':
145
+ case 'decimal':
146
+ return 'float';
147
+ case 'boolean':
148
+ case 'checkbox':
149
+ return 'boolean';
150
+ case 'date':
151
+ case 'time':
152
+ case 'datetime':
153
+ return 'datetime';
154
+ default:
155
+ return 'keyword';
156
+ }
157
+ }
158
+ function coerceFilterValue(value, type) {
159
+ if (value == null)
160
+ return null;
161
+ switch (type) {
162
+ case 'integer':
163
+ case 'float': {
164
+ const n = typeof value === 'number' ? value : Number(value);
165
+ return Number.isFinite(n) ? n : null;
166
+ }
167
+ case 'boolean':
168
+ return typeof value === 'boolean' ? value : null;
169
+ case 'datetime':
170
+ return dateValue(value);
171
+ default:
172
+ return stringValue(value);
173
+ }
174
+ }
175
+ /**
176
+ * Locale-scoped reads deliver flat values; `locale: 'all'` reads deliver
177
+ * `{ en: …, fr: … }` envelopes. Pick the requested locale (or the first
178
+ * available) when an envelope sneaks through.
179
+ */
180
+ function resolveLocalized(value, locale) {
181
+ if (value != null &&
182
+ typeof value === 'object' &&
183
+ !Array.isArray(value) &&
184
+ !(value instanceof Date)) {
185
+ const record = value;
186
+ const keys = Object.keys(record);
187
+ const localeLike = keys.length > 0 && keys.every((k) => /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(k));
188
+ if (localeLike) {
189
+ if (locale && locale in record)
190
+ return record[locale];
191
+ return record[keys[0]];
192
+ }
193
+ }
194
+ return value;
195
+ }
196
+ function asRecord(value) {
197
+ return value != null && typeof value === 'object' && !Array.isArray(value)
198
+ ? value
199
+ : {};
200
+ }
201
+ function nonEmpty(value) {
202
+ return value != null && value.trim().length > 0 ? value : null;
203
+ }
204
+ function stringValue(value) {
205
+ if (typeof value === 'string')
206
+ return value.trim().length > 0 ? value : null;
207
+ if (typeof value === 'number')
208
+ return String(value);
209
+ return null;
210
+ }
211
+ function dateValue(value) {
212
+ if (value instanceof Date)
213
+ return value.toISOString();
214
+ if (typeof value === 'string') {
215
+ const date = new Date(value);
216
+ if (!Number.isNaN(date.getTime()))
217
+ return date.toISOString();
218
+ }
219
+ return null;
220
+ }
@@ -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 {};