diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
index 5f8427117a..1bf655dd95 100644
--- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
+++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
@@ -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]
),
});
diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts
index 21adc30ea8..5ff249b56a 100644
--- a/packages/frontend/core/src/modules/search-menu/services/index.ts
+++ b/packages/frontend/core/src/modules/search-menu/services/index.ts
@@ -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,
};
})
)
diff --git a/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts b/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts
new file mode 100644
index 0000000000..2af177c1c7
--- /dev/null
+++ b/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts
@@ -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('["hello world"]')).toBe(
+ 'hello 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');
+});
diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts
index 999b16109b..0db98ed01a 100644
--- a/packages/frontend/core/src/utils/index.ts
+++ b/packages/frontend/core/src/utils/index.ts
@@ -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';
diff --git a/packages/frontend/core/src/utils/normalize-search-text.ts b/packages/frontend/core/src/utils/normalize-search-text.ts
new file mode 100644
index 0000000000..4a99cd6078
--- /dev/null
+++ b/packages/frontend/core/src/utils/normalize-search-text.ts
@@ -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;
+}