@byline/core 3.15.1 → 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
- import type { CollectionDefinition, RichTextToTextFn, SearchDocument } from '../@types/index.js';
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
- * v1 scope: `search.{body,facets,filters}` name **top-level** fields. Deep
29
- * paths into blocks / arrays are a follow-up.
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
- 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
- }
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.1",
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.1"
82
+ "@byline/auth": "3.15.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",