@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.
- package/dist/@types/admin-types.d.ts +45 -7
- package/dist/@types/collection-types.d.ts +32 -4
- package/dist/@types/field-types.d.ts +43 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/search-types.d.ts +275 -0
- package/dist/@types/search-types.js +8 -0
- package/dist/@types/site-config.d.ts +31 -1
- package/dist/auth/assert-actor-can-perform.test.node.js +9 -1
- package/dist/auth/register-collection-abilities.d.ts +8 -5
- package/dist/auth/register-collection-abilities.js +14 -4
- package/dist/auth/register-collection-abilities.test.node.js +11 -7
- package/dist/config/config.d.ts +12 -1
- package/dist/config/config.js +11 -0
- package/dist/config/resolve-item-view-columns.test.node.d.ts +8 -0
- package/dist/config/resolve-item-view-columns.test.node.js +30 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/schemas/zod/builder.js +16 -5
- package/dist/services/build-search-document.d.ts +46 -0
- package/dist/services/build-search-document.js +220 -0
- package/dist/services/build-search-document.test.node.d.ts +8 -0
- package/dist/services/build-search-document.test.node.js +164 -0
- package/dist/services/index.d.ts +3 -1
- package/dist/services/index.js +3 -1
- package/dist/services/populate.d.ts +6 -0
- package/dist/services/populate.js +19 -1
- package/dist/services/relation-projection.d.ts +4 -15
- package/dist/services/relation-projection.js +19 -6
- package/dist/services/validate-search-config.d.ts +28 -0
- package/dist/services/validate-search-config.js +32 -0
- package/dist/storage/collection-fingerprint.test.node.js +1 -1
- 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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
97
|
-
expect(buckets.get('collections.news')?.length).toBe(
|
|
98
|
-
expect(registry.size).toBe(
|
|
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', () => {
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {};
|