@byline/core 3.15.0 → 3.15.2
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.
|
@@ -5,7 +5,36 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
|
|
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
|
+
* `search.{facets,filters}` name **top-level** fields. `search.body` may name
|
|
29
|
+
* a top-level field of any kind: scalar / `richText` leaves index directly,
|
|
30
|
+
* and container fields (`blocks` / `array` / `group`) are walked recursively —
|
|
31
|
+
* every nested `richText` and text (`text` / `textArea`) leaf is flattened and
|
|
32
|
+
* concatenated into one searchable body string. Nested non-text leaves
|
|
33
|
+
* (`select`, `relation`, numbers, booleans, dates, files) are skipped so block
|
|
34
|
+
* configuration never pollutes the index — the same "content, not
|
|
35
|
+
* configuration" rule the markdown assembler follows.
|
|
36
|
+
*/
|
|
37
|
+
import { type CollectionDefinition, type RichTextToTextFn, type SearchDocument } from '../@types/index.js';
|
|
9
38
|
/** A locale-resolved document fed to the assembler — one locale's view. */
|
|
10
39
|
export interface SearchSourceDocument {
|
|
11
40
|
/** Stable document id (shared across versions and locales). */
|
|
@@ -25,9 +25,16 @@
|
|
|
25
25
|
* in a document whose `facets` relation fields are already populated (depth
|
|
26
26
|
* 1) with the target's identity + counter fields.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* `search.{facets,filters}` name **top-level** fields. `search.body` may name
|
|
29
|
+
* a top-level field of any kind: scalar / `richText` leaves index directly,
|
|
30
|
+
* and container fields (`blocks` / `array` / `group`) are walked recursively —
|
|
31
|
+
* every nested `richText` and text (`text` / `textArea`) leaf is flattened and
|
|
32
|
+
* concatenated into one searchable body string. Nested non-text leaves
|
|
33
|
+
* (`select`, `relation`, numbers, booleans, dates, files) are skipped so block
|
|
34
|
+
* configuration never pollutes the index — the same "content, not
|
|
35
|
+
* configuration" rule the markdown assembler follows.
|
|
30
36
|
*/
|
|
37
|
+
import { isArrayField, isBlocksField, isGroupField, } from '../@types/index.js';
|
|
31
38
|
import { resolveIdentityField } from './populate.js';
|
|
32
39
|
/**
|
|
33
40
|
* Assemble one type-enriched `SearchDocument` from a locale-resolved
|
|
@@ -46,19 +53,7 @@ export function buildSearchDocument(doc, definition, options = {}) {
|
|
|
46
53
|
const field = definition.fields.find((f) => f.name === name);
|
|
47
54
|
if (field == null)
|
|
48
55
|
continue;
|
|
49
|
-
|
|
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
|
-
}
|
|
56
|
+
const value = collectBodyText(field, fieldsData[name], definition.path, locale, options);
|
|
62
57
|
if (value != null) {
|
|
63
58
|
fields.push(withBoost({ name, type: 'text', role: 'body', value }, decl));
|
|
64
59
|
}
|
|
@@ -113,6 +108,83 @@ function withBoost(field, decl) {
|
|
|
113
108
|
const boost = typeof decl === 'string' ? undefined : decl.boost;
|
|
114
109
|
return boost != null ? { ...field, boost } : field;
|
|
115
110
|
}
|
|
111
|
+
/** Text-bearing scalar leaves collected when walking into a container field. */
|
|
112
|
+
const TEXT_LEAF_TYPES = new Set(['text', 'textArea']);
|
|
113
|
+
/**
|
|
114
|
+
* Resolve a single `search.body` field to its searchable text. A scalar /
|
|
115
|
+
* `richText` leaf indexes directly (preserving the original top-level
|
|
116
|
+
* behaviour); a container (`group` / `array` / `blocks`) is walked
|
|
117
|
+
* recursively, flattening every nested text leaf into one string.
|
|
118
|
+
*/
|
|
119
|
+
function collectBodyText(field, rawValue, collectionPath, locale, options) {
|
|
120
|
+
const value = resolveLocalized(rawValue, locale);
|
|
121
|
+
if (isGroupField(field) || isArrayField(field) || isBlocksField(field)) {
|
|
122
|
+
return collectContainerText(field, value, collectionPath, locale, options);
|
|
123
|
+
}
|
|
124
|
+
return collectLeafText(field, value, collectionPath, options, /* permissive */ true);
|
|
125
|
+
}
|
|
126
|
+
/** Walk a container field, concatenating the text of its nested leaves. */
|
|
127
|
+
function collectContainerText(field, value, collectionPath, locale, options) {
|
|
128
|
+
if (isGroupField(field)) {
|
|
129
|
+
return collectFieldSetText(field.fields, asRecord(value), collectionPath, locale, options);
|
|
130
|
+
}
|
|
131
|
+
if (isArrayField(field)) {
|
|
132
|
+
if (!Array.isArray(value))
|
|
133
|
+
return null;
|
|
134
|
+
return joinTexts(value.map((item) => collectFieldSetText(field.fields, asRecord(item), collectionPath, locale, options)));
|
|
135
|
+
}
|
|
136
|
+
// blocks: resolve each item's block definition by `_type`, then walk it.
|
|
137
|
+
if (!isBlocksField(field) || !Array.isArray(value))
|
|
138
|
+
return null;
|
|
139
|
+
const parts = [];
|
|
140
|
+
for (const item of value) {
|
|
141
|
+
const record = asRecord(item);
|
|
142
|
+
const block = field.blocks.find((b) => b.blockType === record._type);
|
|
143
|
+
if (block == null)
|
|
144
|
+
continue;
|
|
145
|
+
parts.push(collectFieldSetText(block.fields, record, collectionPath, locale, options));
|
|
146
|
+
}
|
|
147
|
+
return joinTexts(parts);
|
|
148
|
+
}
|
|
149
|
+
/** Collect text from every text-bearing leaf in a field set (one container level). */
|
|
150
|
+
function collectFieldSetText(fields, data, collectionPath, locale, options) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (const field of fields) {
|
|
153
|
+
const value = resolveLocalized(data?.[field.name], locale);
|
|
154
|
+
if (isGroupField(field) || isArrayField(field) || isBlocksField(field)) {
|
|
155
|
+
parts.push(collectContainerText(field, value, collectionPath, locale, options));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Nested leaves: only text-bearing types contribute — skip configuration
|
|
159
|
+
// (select, relation, numbers, booleans, dates, files).
|
|
160
|
+
parts.push(collectLeafText(field, value, collectionPath, options, /* permissive */ false));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return joinTexts(parts);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Extract text from a single leaf field. `richText` flows through the `toText`
|
|
167
|
+
* seam. For other scalars, `permissive` distinguishes a field the implementor
|
|
168
|
+
* named directly in `search.body` (index any scalar value, as before) from a
|
|
169
|
+
* leaf reached by walking into a container (restricted to text/textArea so
|
|
170
|
+
* block configuration values never leak into the index).
|
|
171
|
+
*/
|
|
172
|
+
function collectLeafText(field, value, collectionPath, options, permissive) {
|
|
173
|
+
if (field.type === 'richText') {
|
|
174
|
+
return options.richTextToText
|
|
175
|
+
? nonEmpty(options.richTextToText({ value, fieldPath: field.name, collectionPath }))
|
|
176
|
+
: null;
|
|
177
|
+
}
|
|
178
|
+
if (permissive || TEXT_LEAF_TYPES.has(field.type)) {
|
|
179
|
+
return stringValue(value);
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
/** Join non-empty text parts with newlines, or null when there is nothing. */
|
|
184
|
+
function joinTexts(parts) {
|
|
185
|
+
const filtered = parts.filter((p) => p != null && p.length > 0);
|
|
186
|
+
return filtered.length > 0 ? filtered.join('\n') : null;
|
|
187
|
+
}
|
|
116
188
|
/** A populated single relation or array of relations → array of envelopes. */
|
|
117
189
|
function toEnvelopeArray(value) {
|
|
118
190
|
if (Array.isArray(value))
|
|
@@ -162,3 +162,114 @@ describe('buildSearchDocument', () => {
|
|
|
162
162
|
expect(out.title).toBe('Restauration');
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
// A collection whose prose lives inside a `blocks` field (the docs shape):
|
|
166
|
+
// RichTextBlock (richText + a checkbox toggle) and PhotoBlock (text alt +
|
|
167
|
+
// richText caption + a select + a relation). Mirrors the real-world case where
|
|
168
|
+
// the searchable body is nested, not top-level.
|
|
169
|
+
const blocksCollection = {
|
|
170
|
+
path: 'articles',
|
|
171
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
172
|
+
useAsTitle: 'title',
|
|
173
|
+
fields: [
|
|
174
|
+
{ name: 'title', label: 'Title', type: 'text', localized: true },
|
|
175
|
+
{
|
|
176
|
+
name: 'content',
|
|
177
|
+
label: 'Content',
|
|
178
|
+
type: 'blocks',
|
|
179
|
+
blocks: [
|
|
180
|
+
{
|
|
181
|
+
blockType: 'richTextBlock',
|
|
182
|
+
label: 'Richtext Block',
|
|
183
|
+
fields: [
|
|
184
|
+
{ name: 'richText', label: 'Richtext', type: 'richText', localized: true },
|
|
185
|
+
{ name: 'constrainedWidth', label: 'Constrained', type: 'checkbox' },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
blockType: 'photoBlock',
|
|
190
|
+
label: 'Photo Block',
|
|
191
|
+
fields: [
|
|
192
|
+
{
|
|
193
|
+
name: 'display',
|
|
194
|
+
label: 'Display',
|
|
195
|
+
type: 'select',
|
|
196
|
+
options: [
|
|
197
|
+
{ label: 'Default', value: 'default' },
|
|
198
|
+
{ label: 'Wide', value: 'wide' },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
{ name: 'photo', label: 'Photo', type: 'relation', targetCollection: 'media' },
|
|
202
|
+
{ name: 'alt', label: 'Alt', type: 'text' },
|
|
203
|
+
{ name: 'caption', label: 'Caption', type: 'richText', localized: true },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
search: { body: [{ field: 'title', boost: 2 }, 'content'] },
|
|
210
|
+
};
|
|
211
|
+
describe('buildSearchDocument — container (blocks) body', () => {
|
|
212
|
+
function blockSource() {
|
|
213
|
+
return {
|
|
214
|
+
documentId: 'art-1',
|
|
215
|
+
locale: 'en',
|
|
216
|
+
status: 'published',
|
|
217
|
+
path: 'forests',
|
|
218
|
+
fields: {
|
|
219
|
+
title: 'Forests',
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
_type: 'richTextBlock',
|
|
223
|
+
_id: 'b1',
|
|
224
|
+
richText: { text: 'None of this is certain. But it reflects what we observed.' },
|
|
225
|
+
constrainedWidth: true,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
_type: 'photoBlock',
|
|
229
|
+
_id: 'b2',
|
|
230
|
+
display: 'wide',
|
|
231
|
+
photo: { _resolved: true, document: { fields: { title: 'A tree' } } },
|
|
232
|
+
alt: 'A tall redwood',
|
|
233
|
+
caption: { text: 'Old growth in the valley.' },
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const doc = buildSearchDocument(blockSource(), blocksCollection, { richTextToText });
|
|
240
|
+
it('walks the blocks field and flattens nested richText + text leaves', () => {
|
|
241
|
+
const content = fieldByName(doc.fields, 'content');
|
|
242
|
+
expect(content).toMatchObject({ name: 'content', type: 'text', role: 'body' });
|
|
243
|
+
const value = content?.value;
|
|
244
|
+
expect(value).toContain('it reflects what we observed');
|
|
245
|
+
expect(value).toContain('A tall redwood');
|
|
246
|
+
expect(value).toContain('Old growth in the valley.');
|
|
247
|
+
});
|
|
248
|
+
it('skips nested non-text leaves (select, relation, checkbox) — no config noise', () => {
|
|
249
|
+
const value = fieldByName(doc.fields, 'content')?.value;
|
|
250
|
+
expect(value).not.toContain('wide'); // select
|
|
251
|
+
expect(value).not.toContain('A tree'); // relation target title
|
|
252
|
+
expect(value).not.toContain('true'); // checkbox
|
|
253
|
+
});
|
|
254
|
+
it('preserves top-level scalar body fields alongside the container', () => {
|
|
255
|
+
expect(fieldByName(doc.fields, 'title')).toMatchObject({ value: 'Forests', boost: 2 });
|
|
256
|
+
});
|
|
257
|
+
it('omits the container body field entirely when no toText seam is registered', () => {
|
|
258
|
+
// With no richText extractor, the only text-bearing leaves left are the
|
|
259
|
+
// PhotoBlock `alt` text.
|
|
260
|
+
const out = buildSearchDocument(blockSource(), blocksCollection);
|
|
261
|
+
const value = fieldByName(out.fields, 'content')?.value;
|
|
262
|
+
expect(value).toBe('A tall redwood');
|
|
263
|
+
});
|
|
264
|
+
it('produces no content field when a block field set has no text', () => {
|
|
265
|
+
const empty = {
|
|
266
|
+
...blockSource(),
|
|
267
|
+
fields: {
|
|
268
|
+
title: 'Forests',
|
|
269
|
+
content: [{ _type: 'photoBlock', _id: 'b3', display: 'wide' }],
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const out = buildSearchDocument(empty, blocksCollection, { richTextToText });
|
|
273
|
+
expect(fieldByName(out.fields, 'content')).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
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": "3.15.
|
|
5
|
+
"version": "3.15.2",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.3",
|
|
82
|
-
"@byline/auth": "3.15.
|
|
82
|
+
"@byline/auth": "3.15.2"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|