feat: normalize search text (#14449)

#### PR Dependency Tree


* **PR #14449** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Search text normalization now applied consistently across doc titles,
search results, and highlights for uniform display formatting.

* **Tests**
* Added comprehensive test coverage for search text normalization
utility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-16 08:07:04 +08:00
committed by GitHub
parent 42f2d2b337
commit e3177e6837
5 changed files with 106 additions and 24 deletions
@@ -6,6 +6,7 @@ import { isEmpty, omit } from 'lodash-es';
import { map, type Observable, of, switchMap } from 'rxjs';
import { z } from 'zod';
import { normalizeSearchText } from '../../../utils/normalize-search-text';
import type { DocsService } from '../../doc/services/docs';
import type { WorkspaceService } from '../../workspace';
@@ -26,24 +27,6 @@ export class DocsSearchService extends Service {
errorMessage: null,
} as IndexerSyncState);
private normalizeHighlight(value?: string | null) {
if (!value) {
return value ?? '';
}
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.join(' ');
}
if (typeof parsed === 'string') {
return parsed;
}
} catch {
// ignore parse errors, return raw value
}
return value;
}
searchTitle$(query: string) {
return this.indexer
.search$(
@@ -144,12 +127,12 @@ export class DocsSearchService extends Service {
const firstMatchFlavour = bucket.hits.nodes[0]?.fields.flavour;
if (firstMatchFlavour === 'affine:page') {
// is title match
const blockContent = this.normalizeHighlight(
const blockContent = normalizeSearchText(
bucket.hits.nodes[1]?.highlights.content[0]
); // try to get block content
result.push({
docId: bucket.key,
title: this.normalizeHighlight(
title: normalizeSearchText(
bucket.hits.nodes[0].highlights.content[0]
),
score: bucket.score,
@@ -169,7 +152,7 @@ export class DocsSearchService extends Service {
? matchedBlockId
: matchedBlockId[0],
score: bucket.score,
blockContent: this.normalizeHighlight(
blockContent: normalizeSearchText(
bucket.hits.nodes[0]?.highlights.content[0]
),
});
@@ -16,6 +16,7 @@ import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { catchError, map, of } from 'rxjs';
import { normalizeSearchText } from '../../../utils/normalize-search-text';
import type { CollectionMeta, CollectionService } from '../../collection';
import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search';
@@ -185,14 +186,16 @@ export class SearchMenuService extends Service {
typeof node.fields.docId === 'string'
? node.fields.docId
: node.fields.docId[0];
const title =
const title = normalizeSearchText(
typeof node.fields.title === 'string'
? node.fields.title
: node.fields.title[0];
: node.fields.title[0]
);
const highlights = normalizeSearchText(node.highlights?.title?.[0]);
return {
id,
title,
highlights: node.highlights?.title?.[0],
highlights: highlights || undefined,
};
})
)
@@ -0,0 +1,33 @@
import { expect, test } from 'vitest';
import { normalizeSearchText } from '../normalize-search-text';
test('normalizeSearchText should keep plain text unchanged', () => {
expect(normalizeSearchText('hello world')).toBe('hello world');
});
test('normalizeSearchText should decode serialized single-item array', () => {
expect(normalizeSearchText('["hello world"]')).toBe('hello world');
});
test('normalizeSearchText should decode serialized highlighted array', () => {
expect(normalizeSearchText('["<b>hello</b> world"]')).toBe(
'<b>hello</b> world'
);
});
test('normalizeSearchText should join serialized multi-item array', () => {
expect(normalizeSearchText('["hello","world"]')).toBe('hello world');
});
test('normalizeSearchText should decode serialized string', () => {
expect(normalizeSearchText('"hello world"')).toBe('hello world');
});
test('normalizeSearchText should keep invalid serialized text unchanged', () => {
expect(normalizeSearchText('["hello"')).toBe('["hello"');
});
test('normalizeSearchText should support array input', () => {
expect(normalizeSearchText(['hello', 'world'])).toBe('hello world');
});
@@ -2,6 +2,7 @@ export * from './channel';
export * from './create-emotion-cache';
export * from './event';
export * from './extract-emoji-icon';
export * from './normalize-search-text';
export * from './string2color';
export * from './toast';
export * from './unflatten-object';
@@ -0,0 +1,62 @@
interface NormalizeSearchTextOptions {
fallback?: string;
arrayJoiner?: string;
}
function tryParseSerializedText(value: string): unknown | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
// Indexer/storage may serialize text arrays as JSON strings like ["foo"].
if (!trimmed.startsWith('[') && !trimmed.startsWith('"')) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
export function normalizeSearchText(
value: unknown,
{ fallback = '', arrayJoiner = ' ' }: NormalizeSearchTextOptions = {}
): string {
if (value === null || value === undefined) {
return fallback;
}
if (Array.isArray(value)) {
const normalized = value
.map(item =>
normalizeSearchText(item, {
fallback: '',
arrayJoiner,
})
)
.filter(Boolean);
return normalized.length > 0 ? normalized.join(arrayJoiner) : fallback;
}
if (typeof value !== 'string') {
return String(value);
}
const parsed = tryParseSerializedText(value);
if (parsed === null) {
return value;
}
if (typeof parsed === 'string' || Array.isArray(parsed)) {
return normalizeSearchText(parsed, {
fallback,
arrayJoiner,
});
}
return value;
}