Skip to content

Commit 6f87dd3

Browse files
atscottAndrewKushnir
authored andcommitted
refactor(docs-infra): Update search results to display content when it is matched (angular#57298)
This commit updates the search results to query for the content as well as a snippet of the content for display when it's the content that matches the query rather than any of the headers. PR Close angular#57298
1 parent e4a6198 commit 6f87dd3

File tree

6 files changed

+181
-56
lines changed

6 files changed

+181
-56
lines changed

adev/shared-docs/components/search-dialog/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ ng_module(
2222
"//adev/shared-docs/interfaces",
2323
"//adev/shared-docs/pipes",
2424
"//adev/shared-docs/services",
25+
"//packages/common",
2526
"//packages/core",
2627
"//packages/forms",
2728
"//packages/router",

adev/shared-docs/components/search-dialog/search-dialog.component.html

Lines changed: 62 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,65 +10,62 @@
1010
></docs-text-field>
1111

1212
@if (searchResults() && searchResults()!.length > 0) {
13-
<ul class="docs-search-results docs-mini-scroll-track">
14-
@for (result of searchResults(); track result.objectID) {
15-
<li docsSearchItem [item]="result">
16-
@if (result.url) {
17-
<a [routerLink]="'/' + result.url | relativeLink: 'pathname'" [fragment]="result.url | relativeLink: 'hash'">
18-
<div>
19-
<div class="docs-result-icon-and-type">
20-
<!-- Icon -->
21-
<span class="docs-search-result-icon" aria-hidden="true">
22-
@if (result.hierarchy?.lvl0 === 'Docs') {
23-
<i role="presentation" class="material-symbols-outlined docs-icon-small">
24-
description
25-
</i>
26-
} @else if (result.hierarchy?.lvl0 === 'Tutorials') {
27-
<i role="presentation" class="material-symbols-outlined docs-icon-small">code</i>
28-
} @else if (result.hierarchy?.lvl0 === 'Reference') {
29-
<i role="presentation" class="material-symbols-outlined docs-icon-small">
30-
description
31-
</i>
32-
}
33-
</span>
34-
<!-- Results type -->
35-
<span class="docs-search-results__type">{{ result.hierarchy?.lvl1 }}</span>
36-
</div>
13+
<ul class="docs-search-results docs-mini-scroll-track">
14+
@for (result of searchResults(); track result.objectID) {
15+
<li docsSearchItem [item]="result">
16+
<a
17+
[routerLink]="'/' + result.url | relativeLink: 'pathname'"
18+
[fragment]="result.url | relativeLink: 'hash'"
19+
>
20+
<div>
21+
<div class="docs-result-icon-and-type">
22+
<!-- Icon -->
23+
<span class="docs-search-result-icon" aria-hidden="true">
24+
<i role="presentation" class="material-symbols-outlined docs-icon-small">
25+
{{ result.hierarchy.lvl0 === 'Tutorials' ? 'code' : 'description'}}
26+
</i>
27+
</span>
28+
<!-- Results type -->
29+
<span class="docs-search-results__type">
30+
@let snippet = result._snippetResult.hierarchy?.lvl1?.value ?? '';
31+
<ng-container
32+
[ngTemplateOutlet]="highlightSnippet"
33+
[ngTemplateOutletContext]="{snippet}"
34+
></ng-container>
35+
</span>
36+
</div>
3737

38-
<!-- Hide level 2 if level 3 exists -->
39-
<!-- Level 2 -->
40-
@if (result.hierarchy?.lvl2 && !result.hierarchy?.lvl3) {
41-
<span class="docs-search-results__type docs-search-results__lvl2">
42-
{{ result.hierarchy?.lvl2 }}
43-
</span>
44-
}
45-
<!-- Level 3 -->
46-
@if (result.hierarchy?.lvl3) {
47-
<span class="docs-search-results__type docs-search-results__lvl3">
48-
{{ result.hierarchy?.lvl3 }}
49-
</span>
50-
}
51-
</div>
38+
@let content = result._snippetResult.content;
39+
@let hierarchy = result._snippetResult.hierarchy;
40+
@if (content || hierarchy?.lvl2 || hierarchy?.lvl3 || hierarchy?.lvl4) {
41+
<span class="docs-search-results__type docs-search-results__lvl2">
42+
@let snippet = getBestSnippetForMatch(result);
43+
<ng-container
44+
[ngTemplateOutlet]="highlightSnippet"
45+
[ngTemplateOutletContext]="{snippet}"
46+
></ng-container>
47+
</span>
48+
}
49+
</div>
5250

53-
<!-- Page title -->
54-
<span class="docs-result-page-title">{{ result.hierarchy?.lvl0 }}</span>
55-
</a>
51+
<!-- Page title -->
52+
<span class="docs-result-page-title">{{ result.hierarchy?.lvl0 }}</span>
53+
</a>
54+
</li>
5655
}
57-
</li>
58-
}
59-
</ul>
56+
</ul>
6057
} @else {
61-
<div class="docs-search-results docs-mini-scroll-track">
62-
@if (searchResults() === undefined) {
63-
<div class="docs-search-results__start-typing">
64-
<span>Start typing to see results</span>
65-
</div>
66-
} @else if (searchResults()?.length === 0) {
67-
<div class="docs-search-results__no-results">
68-
<span>No results found</span>
58+
<div class="docs-search-results docs-mini-scroll-track">
59+
@if (searchResults() === undefined) {
60+
<div class="docs-search-results__start-typing">
61+
<span>Start typing to see results</span>
62+
</div>
63+
} @else if (searchResults()?.length === 0) {
64+
<div class="docs-search-results__no-results">
65+
<span>No results found</span>
66+
</div>
67+
}
6968
</div>
70-
}
71-
</div>
7269
}
7370

7471
<div class="docs-algolia">
@@ -79,3 +76,14 @@
7976
</div>
8077
</div>
8178
</dialog>
79+
80+
<ng-template #highlightSnippet let-snippet="snippet">
81+
@let parts = splitMarkedText(snippet);
82+
@for (part of parts; track $index) {
83+
@if (part.highlight) {
84+
<mark>{{part.text}}</mark>
85+
} @else {
86+
<span>{{part.text}}</span>
87+
}
88+
}
89+
</ng-template>

adev/shared-docs/components/search-dialog/search-dialog.component.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ dialog {
4848
padding-inline-end: 1rem;
4949
padding-block: 0.25rem;
5050

51+
mark {
52+
background: #e62600;
53+
background: var(--red-to-orange-horizontal-gradient);
54+
background-clip: text;
55+
-webkit-background-clip: text;
56+
color: transparent;
57+
}
58+
5159
a {
5260
color: var(--secondary-contrast);
5361
display: flex;

adev/shared-docs/components/search-dialog/search-dialog.component.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
viewChild,
2121
viewChildren,
2222
} from '@angular/core';
23+
import {NgTemplateOutlet} from '@angular/common';
2324

2425
import {WINDOW} from '../../providers/index';
2526
import {ClickOutside} from '../../directives/index';
@@ -34,6 +35,7 @@ import {Router, RouterLink} from '@angular/router';
3435
import {filter, fromEvent} from 'rxjs';
3536
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
3637
import {RelativeLink} from '../../pipes/relative-link.pipe';
38+
import {SearchResult, SnippetResult} from '../../interfaces';
3739

3840
@Component({
3941
selector: 'docs-search-dialog',
@@ -47,6 +49,7 @@ import {RelativeLink} from '../../pipes/relative-link.pipe';
4749
AlgoliaIcon,
4850
RelativeLink,
4951
RouterLink,
52+
NgTemplateOutlet,
5053
],
5154
templateUrl: './search-dialog.component.html',
5255
styleUrls: ['./search-dialog.component.scss'],
@@ -104,6 +107,46 @@ export class SearchDialog implements OnDestroy {
104107
});
105108
}
106109

110+
splitMarkedText(snippet: string): Array<{highlight: boolean; text: string}> {
111+
const parts: Array<{highlight: boolean; text: string}> = [];
112+
while (snippet.indexOf('<ɵ>') !== -1) {
113+
const beforeMatch = snippet.substring(0, snippet.indexOf('<ɵ>'));
114+
const match = snippet.substring(snippet.indexOf('<ɵ>') + 3, snippet.indexOf('</ɵ>'));
115+
parts.push({highlight: false, text: beforeMatch});
116+
parts.push({highlight: true, text: match});
117+
snippet = snippet.substring(snippet.indexOf('</ɵ>') + 4);
118+
}
119+
parts.push({highlight: false, text: snippet});
120+
return parts;
121+
}
122+
123+
getBestSnippetForMatch(result: SearchResult): string {
124+
// if there is content, return it
125+
if (result._snippetResult.content !== undefined) {
126+
return result._snippetResult.content.value;
127+
}
128+
129+
const hierarchy = result._snippetResult.hierarchy;
130+
if (hierarchy === undefined) {
131+
return '';
132+
}
133+
function matched(snippet: SnippetResult | undefined) {
134+
return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none';
135+
}
136+
// return the most specific subheader match
137+
if (matched(hierarchy.lvl4)) {
138+
return hierarchy.lvl4!.value;
139+
}
140+
if (matched(hierarchy.lvl3)) {
141+
return hierarchy.lvl3!.value;
142+
}
143+
if (matched(hierarchy.lvl2)) {
144+
return hierarchy.lvl2!.value;
145+
}
146+
// if no subheader matched the query, fall back to just returning the most specific one
147+
return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? '';
148+
}
149+
107150
ngOnDestroy(): void {
108151
this.keyManager.destroy();
109152
}

adev/shared-docs/interfaces/search-results.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,39 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
export interface SnippetResult {
10+
value: string;
11+
matchLevel: 'none' | 'full' | string;
12+
}
13+
914
/* The interface represents Algolia search result item. */
1015
export interface SearchResult {
1116
/* The url link to the search result page */
12-
url?: string;
17+
url: string;
1318
/* The hierarchy of the item */
14-
hierarchy?: Hierarchy;
19+
hierarchy: Hierarchy;
1520
/* The unique id of the search result item */
1621
objectID: string;
22+
/**
23+
* The type of the result. A content result will have
24+
* matched the content. A result of type 'lvl#' may have i
25+
* matched a lvl above it. For example, a type 'lvl3' may be
26+
* included in results because its 'lvl2' header matched the query.
27+
*/
28+
type: string;
29+
/** Documentation content (not headers) */
30+
content: string | null;
31+
/** Snippets of the matched text */
32+
_snippetResult: {
33+
hierarchy?: {
34+
lvl0?: SnippetResult;
35+
lvl1?: SnippetResult;
36+
lvl2?: SnippetResult;
37+
lvl3?: SnippetResult;
38+
lvl4?: SnippetResult;
39+
};
40+
content?: SnippetResult;
41+
};
1742
}
1843

1944
/* The hierarchy of the item */

adev/shared-docs/services/search.service.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ export class Search {
4343
? from(
4444
this.index.search(query, {
4545
maxValuesPerFacet: MAX_VALUE_PER_FACET,
46+
attributesToRetrieve: [
47+
'hierarchy.lvl0',
48+
'hierarchy.lvl1',
49+
'hierarchy.lvl2',
50+
'hierarchy.lvl3',
51+
'hierarchy.lvl4',
52+
'hierarchy.lvl5',
53+
'hierarchy.lvl6',
54+
'content',
55+
'type',
56+
'url',
57+
],
58+
hitsPerPage: 20,
59+
snippetEllipsisText: '…',
60+
highlightPreTag: '<ɵ>',
61+
highlightPostTag: '</ɵ>',
62+
attributesToHighlight: [],
63+
attributesToSnippet: [
64+
'hierarchy.lvl1:10',
65+
'hierarchy.lvl2:10',
66+
'hierarchy.lvl3:10',
67+
'hierarchy.lvl4:10',
68+
'hierarchy.lvl5:10',
69+
'hierarchy.lvl6:10',
70+
'content:10',
71+
],
4672
}),
4773
)
4874
: of(undefined);
@@ -72,6 +98,20 @@ export class Search {
7298
const uniqueUrls = new Set<string>();
7399

74100
return items.filter((item) => {
101+
if (item.type === 'content' && !item._snippetResult.content) {
102+
return false;
103+
}
104+
// Ensure that this result actually matched on the type.
105+
// If not, this is going to be a duplicate. There should be another result in
106+
// the list that already matched on its type.
107+
// A lvl2 match will also return all its lvl3 results as well, even if those
108+
// values don't also match the query.
109+
if (
110+
item.type.indexOf('lvl') === 0 &&
111+
item._snippetResult.hierarchy?.[item.type as 'lvl1']?.matchLevel === 'none'
112+
) {
113+
return false;
114+
}
75115
if (item.url && !uniqueUrls.has(item.url)) {
76116
uniqueUrls.add(item.url);
77117
return true;

0 commit comments

Comments
 (0)