Skip to content

Commit 1788aa5

Browse files
bors[bot]bidoubiwa
andauthored
Merge #605
605: Fix snippeting r=bidoubiwa a=bidoubiwa Co-authored-by: Charlotte Vermandel <[email protected]>
2 parents ac7dd15 + 4c15b06 commit 1788aa5

File tree

12 files changed

+335
-268
lines changed

12 files changed

+335
-268
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"url": "https://github.com/meilisearch/instant-meilisearch.git"
5555
},
5656
"dependencies": {
57-
"meilisearch": "^0.22.3"
57+
"meilisearch": "^0.23.0"
5858
},
5959
"devDependencies": {
6060
"@babel/cli": "^7.16.0",

playgrounds/angular/src/app/app.component.html

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ <h1 class="header-title">MeiliSearch + Angular InstantSearch</h1>
1010
<div class="search-panel">
1111
<div class="search-panel__filters">
1212
<ais-clear-refinements></ais-clear-refinements>
13-
<ais-sort-by
14-
[items]="[
13+
<ais-sort-by [items]="[
1514
{ value: 'steam-video-games', label: 'Relevant' },
1615
{
1716
value: 'steam-video-games:recommendationCount:desc',
@@ -21,17 +20,18 @@ <h1 class="header-title">MeiliSearch + Angular InstantSearch</h1>
2120
value: 'steam-video-games:recommendationCount:asc',
2221
label: 'Least Recommended'
2322
}
24-
]"
25-
></ais-sort-by>
26-
<ais-configure [searchParameters]="{ hitsPerPage: 6 }"></ais-configure>
23+
]"></ais-sort-by>
24+
<ais-configure
25+
[searchParameters]="{ hitsPerPage: 6, attributesToSnippet: ['description:10'], snippetEllipsisText:'...' }">
26+
</ais-configure>
2727
<h2>Genres</h2>
28-
<ais-refinement-list attribute="genres" ></ais-refinement-list>
28+
<ais-refinement-list attribute="genres"></ais-refinement-list>
2929
<h2>Players</h2>
30-
<ais-refinement-list attribute="players" ></ais-refinement-list>
30+
<ais-refinement-list attribute="players"></ais-refinement-list>
3131
<h2>Platforms</h2>
32-
<ais-refinement-list attribute="platforms" ></ais-refinement-list>
32+
<ais-refinement-list attribute="platforms"></ais-refinement-list>
3333
<h2>Misc</h2>
34-
<ais-refinement-list attribute="misc" ></ais-refinement-list>
34+
<ais-refinement-list attribute="misc"></ais-refinement-list>
3535
</div>
3636

3737
<div class="search-panel__results">
@@ -51,7 +51,8 @@ <h2>Misc</h2>
5151
<ais-highlight attribute="name" [hit]="hit"></ais-highlight>
5252
</div>
5353
<div class="hit-description">
54-
<ais-highlight attribute="description" [hit]="hit"></ais-highlight>
54+
<ais-snippet attribute="description" [hit]="hit">
55+
</ais-snippet>
5556
</div>
5657
<div class="hit-info">price: ${{hit.price}}</div>
5758
<div class="hit-info">Release date: {{hit.releaseDate}}</div>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { adaptHighlight } from './highlight-adapter'
2+
import { adaptSnippet } from './snippet-adapter'
3+
import { SearchContext } from '../../../types'
4+
5+
/**
6+
* Adapt MeiliSearch formating to formating compliant with instantsearch.js.
7+
*
8+
* @param {Record<string} formattedHit
9+
* @param {SearchContext} searchContext
10+
* @returns {Record}
11+
*/
12+
export function adaptFormating(
13+
hit: Record<string, any>,
14+
searchContext: SearchContext
15+
): Record<string, any> {
16+
const attributesToSnippet = searchContext?.attributesToSnippet
17+
const ellipsis = searchContext?.snippetEllipsisText
18+
const preTag = searchContext?.highlightPreTag
19+
const postTag = searchContext?.highlightPostTag
20+
21+
if (!hit._formatted) return {}
22+
const _highlightResult = adaptHighlight(hit, preTag, postTag)
23+
24+
// what is ellipsis by default
25+
const _snippetResult = adaptHighlight(
26+
adaptSnippet(hit, attributesToSnippet, ellipsis),
27+
preTag,
28+
postTag
29+
)
30+
31+
const highlightedHit = {
32+
_highlightResult,
33+
_snippetResult,
34+
}
35+
36+
return highlightedHit
37+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { isString } from '../../../utils'
2+
/**
3+
* Replace `em` tags in highlighted MeiliSearch hits to
4+
* provided tags by instantsearch.js.
5+
*
6+
* @param {string} value
7+
* @param {string} highlightPreTag?
8+
* @param {string} highlightPostTag?
9+
* @returns {string}
10+
*/
11+
function replaceDefaultEMTag(
12+
value: any,
13+
preTag = '__ais-highlight__',
14+
postTag = '__/ais-highlight__'
15+
): string {
16+
// Highlight is applied by MeiliSearch (<em> tags)
17+
// We replace the <em> by the expected tag for InstantSearch
18+
const stringifiedValue = isString(value) ? value : JSON.stringify(value)
19+
20+
return stringifiedValue.replace(/<em>/g, preTag).replace(/<\/em>/g, postTag)
21+
}
22+
23+
function addHighlightTags(
24+
value: any,
25+
preTag?: string,
26+
postTag?: string
27+
): string {
28+
if (typeof value === 'string') {
29+
// String
30+
return replaceDefaultEMTag(value, preTag, postTag)
31+
} else if (value === undefined) {
32+
// undefined
33+
return JSON.stringify(null)
34+
} else {
35+
// Other
36+
return JSON.stringify(value)
37+
}
38+
}
39+
40+
export function resolveHighlightValue(
41+
value: any,
42+
preTag?: string,
43+
postTag?: string
44+
): { value: string } | Array<{ value: string }> {
45+
if (Array.isArray(value)) {
46+
// Array
47+
return value.map((elem) => ({
48+
value: addHighlightTags(elem, preTag, postTag),
49+
}))
50+
} else {
51+
return { value: addHighlightTags(value, preTag, postTag) }
52+
}
53+
}
54+
55+
/**
56+
* @param {Record<string} formattedHit
57+
* @param {string} highlightPreTag?
58+
* @param {string} highlightPostTag?
59+
* @returns {Record}
60+
*/
61+
export function adaptHighlight(
62+
hit: Record<string, any>,
63+
preTag?: string,
64+
postTag?: string
65+
): Record<string, any> {
66+
// hit is the `_formatted` object returned by MeiliSearch.
67+
// It contains all the highlighted and croped attributes
68+
69+
if (!hit._formatted) return hit._formatted
70+
return Object.keys(hit._formatted).reduce((result, key) => {
71+
const value = hit._formatted[key]
72+
73+
result[key] = resolveHighlightValue(value, preTag, postTag)
74+
return result
75+
}, {} as any)
76+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './format-adapter'
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { isString } from '../../../utils'
2+
3+
function nakedOfTags(str: string) {
4+
return str.replace(/<em>/g, '').replace(/<\/em>/g, '')
5+
}
6+
7+
function addEllipsis(value: any, formatValue: string, ellipsis: string): any {
8+
// Manage ellpsis on cropped values until this feature is implemented https://roadmap.meilisearch.com/c/69-policy-for-cropped-values?utm_medium=social&utm_source=portal_share in MeiliSearch
9+
10+
let ellipsedValue = formatValue
11+
12+
if (
13+
isString(formatValue) &&
14+
value.toString().length > nakedOfTags(formatValue).length
15+
) {
16+
if (
17+
formatValue[0] === formatValue[0].toLowerCase() && // beginning of a sentence
18+
formatValue.startsWith('<em>') === false // beginning of the document field, otherwise MeiliSearch would crop around the highlight
19+
) {
20+
ellipsedValue = `${ellipsis}${formatValue.trim()}`
21+
}
22+
if (!!formatValue.match(/[.!?]$/) === false) {
23+
// end of the sentence
24+
ellipsedValue = `${formatValue.trim()}${ellipsis}`
25+
}
26+
}
27+
return ellipsedValue
28+
}
29+
30+
/**
31+
* @param {string} value
32+
* @param {string} ellipsis?
33+
* @returns {string}
34+
*/
35+
function resolveSnippet(value: any, formatValue: any, ellipsis?: string): any {
36+
if (!ellipsis || !(typeof formatValue === 'string')) {
37+
return formatValue
38+
} else if (Array.isArray(value)) {
39+
// Array
40+
return value.map((elem) => addEllipsis(elem, formatValue, ellipsis))
41+
}
42+
return addEllipsis(value, formatValue, ellipsis)
43+
}
44+
45+
/**
46+
* @param {Record<string} hit
47+
* @param {readonlystring[]|undefined} attributes
48+
* @param {string|undefined} ellipsis
49+
*/
50+
export function adaptSnippet(
51+
hit: Record<string, any>,
52+
attributes: readonly string[] | undefined,
53+
ellipsis: string | undefined
54+
): Record<string, any> {
55+
// hit is the `_formatted` object returned by MeiliSearch.
56+
// It contains all the highlighted and croped attributes
57+
58+
const formattedHit = hit._formatted
59+
const newHit = hit._formatted
60+
61+
if (attributes === undefined) {
62+
return hit
63+
}
64+
65+
// All attributes that should be snippeted and their snippet size
66+
const snippets = attributes.map(
67+
(attribute) => attribute.split(':')[0]
68+
) as any[]
69+
70+
// Find presence of a wildcard *
71+
const wildCard = snippets.includes('*')
72+
73+
if (wildCard) {
74+
// In case of *
75+
for (const attribute in formattedHit) {
76+
newHit[attribute] = resolveSnippet(
77+
hit[attribute],
78+
formattedHit[attribute],
79+
ellipsis
80+
)
81+
}
82+
} else {
83+
// Itterate on all attributes that needs snippeting
84+
for (const attribute of snippets) {
85+
newHit[attribute] = resolveSnippet(
86+
hit[attribute],
87+
formattedHit[attribute],
88+
ellipsis
89+
)
90+
}
91+
}
92+
hit._formatted = newHit
93+
94+
return hit
95+
}

0 commit comments

Comments
 (0)