Skip to content

Commit d89e1b1

Browse files
authored
Replace search UI with Algolia backed one (#845)
1 parent b6dd4b7 commit d89e1b1

File tree

8 files changed

+200
-125
lines changed

8 files changed

+200
-125
lines changed

docs/.hooks/algolia.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class AlgoliaRecord(TypedDict):
2121
abs_url: str
2222
title: str
2323
objectID: str
24+
rank: int
2425

2526

2627
records: list[AlgoliaRecord] = []
@@ -42,6 +43,13 @@ def on_page_content(html: str, page: Page, config: Config, files: Files) -> str:
4243

4344
soup = BeautifulSoup(html, 'html.parser')
4445

46+
# If the page does not start with a heading, add the h1 with the title
47+
# Some examples don't have a heading. or start with h2
48+
first_element = soup.find()
49+
50+
if not first_element or not first_element.name or first_element.name not in ['h1', 'h2', 'h3']:
51+
soup.insert(0, BeautifulSoup(f'<h1 id="{title}">{title}</h1>', 'html.parser'))
52+
4553
# Clean up presentational and UI elements
4654
for element in soup.find_all(['autoref']):
4755
element.decompose()
@@ -62,8 +70,10 @@ def on_page_content(html: str, page: Page, config: Config, files: Files) -> str:
6270
for extra in soup.find_all('table', attrs={'class': 'highlighttable'}):
6371
extra.replace_with(BeautifulSoup(f'<pre>{extra.find("code").get_text()}</pre>', 'html.parser'))
6472

65-
# Find all h1 and h2 headings
66-
headings = soup.find_all(['h1', 'h2'])
73+
headings = soup.find_all(['h1', 'h2', 'h3'])
74+
75+
# Use the rank to put the sections in the beginning higher in the search results
76+
rank = 100
6777

6878
# Process each section
6979
for current_heading in headings:
@@ -73,26 +83,41 @@ def on_page_content(html: str, page: Page, config: Config, files: Files) -> str:
7383
# Get content until next heading
7484
content: list[str] = []
7585
sibling = current_heading.find_next_sibling()
76-
while sibling and sibling.name not in {'h1', 'h2'}:
86+
while sibling and sibling.name not in {'h1', 'h2', 'h3'}:
7787
content.append(str(sibling))
7888
sibling = sibling.find_next_sibling()
7989

8090
section_html = ''.join(content)
8191

92+
section_soup = BeautifulSoup(section_html, 'html.parser')
93+
section_plain_text = section_soup.get_text(' ', strip=True)
94+
8295
# Create anchor URL
8396
anchor_url: str = f'{page.abs_url}#{heading_id}' if heading_id else page.abs_url or ''
8497

98+
record_title = title
99+
100+
if current_heading.name == 'h2':
101+
record_title = f'{title} - {section_title}'
102+
elif current_heading.name == 'h3':
103+
previous_heading = current_heading.find_previous(['h1', 'h2'])
104+
record_title = f'{title} - {previous_heading.get_text()} - {section_title}'
105+
106+
# print(f'Adding record {record_title}, {rank}, {current_heading.name}')
85107
# Create record for this section
86108
records.append(
87109
AlgoliaRecord(
88-
content=section_html,
110+
content=section_plain_text,
89111
pageID=title,
90112
abs_url=anchor_url,
91-
title=f'{title} - {section_title}',
113+
title=record_title,
92114
objectID=anchor_url,
115+
rank=rank,
93116
)
94117
)
95118

119+
rank -= 5
120+
96121
return html
97122

98123

@@ -132,6 +157,16 @@ def algolia_upload() -> None:
132157
print(f'Uploading {len(filtered_records)} out of {len(all_records)} records to Algolia...')
133158

134159
client.clear_objects(index_name=ALGOLIA_INDEX_NAME)
160+
client.set_settings(
161+
index_name=ALGOLIA_INDEX_NAME,
162+
index_settings={
163+
'searchableAttributes': ['title', 'content'],
164+
'attributesToSnippet': ['content:40'],
165+
'customRanking': [
166+
'desc(rank)',
167+
],
168+
},
169+
)
135170

136171
client.batch(
137172
index_name=ALGOLIA_INDEX_NAME,

docs/.overrides/main.html

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,5 @@
11
{% extends "base.html" %}
22

3-
<!--
4-
We're changing the config block so that we can change the worker script path to a custom web worker script that pulls search results from Algolia.
5-
The rest of the block is the "stock" one, so it can safely be updated if something changes in MkDocs.
6-
-->
7-
8-
{% block config %}
9-
{%- set app = {
10-
"base": base_url,
11-
"features": features,
12-
"translations": {},
13-
"search": base_url + "/javascripts/search-worker.js" | url
14-
} -%}
15-
16-
<!-- Versioning -->
17-
{%- if config.extra.version -%}
18-
{%- set mike = config.plugins.get("mike") -%}
19-
{%- if not mike or mike.config.version_selector -%}
20-
{%- set _ = app.update({ "version": config.extra.version }) -%}
21-
{%- endif -%}
22-
{%- endif -%}
23-
24-
<!-- Tags -->
25-
{%- if config.extra.tags -%}
26-
{%- set _ = app.update({ "tags": config.extra.tags }) -%}
27-
{%- endif -%}
28-
29-
<!-- Translations -->
30-
{%- set translations = app.translations -%}
31-
{%- for key in [
32-
"clipboard.copy",
33-
"clipboard.copied",
34-
"search.result.placeholder",
35-
"search.result.none",
36-
"search.result.one",
37-
"search.result.other",
38-
"search.result.more.one",
39-
"search.result.more.other",
40-
"search.result.term.missing",
41-
"select.version"
42-
] -%}
43-
{%- set _ = translations.update({ key: lang.t(key) }) -%}
44-
{%- endfor -%}
45-
46-
<!-- Configuration -->
47-
<script id="__config" type="application/json">
48-
{{- app | tojson -}}
49-
</script>
50-
{% endblock %}
51-
523
{% block content %}
534
<div id="version-notice"></div>
545
{{ super() }}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<div class="md-search" role="dialog">
2+
<label class="md-search__overlay" for="__search"></label>
3+
<div class="md-search__inner" role="search">
4+
<form class="md-search__form" name="search">
5+
<input type="text" id="searchbox" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" required="">
6+
<label class="md-search__icon md-icon" for="__search">
7+
8+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"></path></svg>
9+
10+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12Z"></path></svg>
11+
</label>
12+
<nav class="md-search__options" aria-label="Search">
13+
14+
<button id="searchbox-clear" type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
15+
16+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path></svg>
17+
</button>
18+
</nav>
19+
20+
<div class="md-search__suggest"></div>
21+
22+
</form>
23+
<div class="md-search__output">
24+
<div class="md-search__scrollwrap" tabindex="0">
25+
<div class="md-search-result">
26+
<div class="md-search-result__meta" id="type-to-start-searching">Type to start searching</div>
27+
<ol class="md-search-result__list" id="hits" role="presentation"></ol>
28+
</div>
29+
</div>
30+
</div>
31+
</div>
32+
</div>

docs/extra/tweaks.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,20 @@ img.index-header {
5858
.mermaid {
5959
text-align: center;
6060
}
61+
62+
.md-search__input::-webkit-search-decoration,
63+
.md-search__input::-webkit-search-cancel-button,
64+
.md-search__input::-webkit-search-results-button,
65+
.md-search__input::-webkit-search-results-decoration {
66+
-webkit-appearance:none;
67+
}
68+
69+
.md-search-result__article {
70+
padding-bottom: .55em;
71+
}
72+
73+
.ais-SearchBox-form {
74+
display: flex;
75+
flex-direction: row;
76+
gap: 10px;
77+
}

docs/javascripts/algolia-search.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const ALGOLIA_APP_ID = 'KPPUDTIAVX';
2+
const ALGOLIA_API_KEY = '1fc841595212a2c3afe8c24dd4cb8790';
3+
const ALGOLIA_INDEX_NAME = 'pydantic-ai-docs';
4+
5+
const { liteClient: algoliasearch } = window['algoliasearch/lite'];
6+
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY);
7+
8+
const search = instantsearch({
9+
indexName: ALGOLIA_INDEX_NAME,
10+
searchClient,
11+
searchFunction(helper) {
12+
const query = helper.state.query
13+
14+
if (query && query.length > 1) {
15+
document.querySelector('#hits').hidden = false
16+
document.querySelector('#type-to-start-searching').hidden = true
17+
helper.search();
18+
} else {
19+
document.querySelector('#hits').hidden = true
20+
document.querySelector('#type-to-start-searching').hidden = false
21+
}
22+
},
23+
});
24+
25+
// create custom widget, to integrate with MkDocs built-in markup
26+
const customSearchBox = instantsearch.connectors.connectSearchBox((renderOptions, isFirstRender) => {
27+
const { query, refine, clear } = renderOptions;
28+
29+
if (isFirstRender) {
30+
document.querySelector('#searchbox').addEventListener('input', event => {
31+
refine(event.target.value);
32+
});
33+
34+
document.querySelector('#searchbox').addEventListener('focus', () => {
35+
document.querySelector('#__search').checked = true;
36+
});
37+
38+
document.querySelector('#searchbox-clear').addEventListener('click', () => {
39+
clear();
40+
});
41+
42+
document.querySelector('#searchbox').addEventListener('keydown', (event) => {
43+
// on down arrow, find the first search result and focus it
44+
if (event.key === 'ArrowDown') {
45+
document.querySelector('.md-search-result__link').focus();
46+
event.preventDefault();
47+
}
48+
});
49+
50+
// for Hits, add keyboard navigation
51+
document.querySelector('#hits').addEventListener('keydown', (event) => {
52+
if (event.key === 'ArrowDown') {
53+
const next = event.target.parentElement.nextElementSibling;
54+
if (next) {
55+
next.querySelector('.md-search-result__link').focus();
56+
event.preventDefault();
57+
}
58+
} else if (event.key === 'ArrowUp') {
59+
const prev = event.target.parentElement.previousElementSibling;
60+
if (prev) {
61+
prev.querySelector('.md-search-result__link').focus();
62+
} else {
63+
document.querySelector('#searchbox').focus();
64+
}
65+
event.preventDefault();
66+
}
67+
})
68+
69+
document.addEventListener('keydown', (event) => {
70+
// if forward slash is pressed, focus the search box
71+
if (event.key === '/' && event.target.tagName !== 'INPUT') {
72+
document.querySelector('#searchbox').focus();
73+
event.preventDefault();
74+
}
75+
})
76+
}
77+
78+
79+
document.querySelector('#type-to-start-searching').hidden = query.length > 1;
80+
document.querySelector('#searchbox').value = query;
81+
});
82+
83+
search.addWidgets([
84+
customSearchBox({}),
85+
86+
instantsearch.widgets.hits({
87+
container: '#hits',
88+
cssClasses: {
89+
'list': 'md-search-result__list',
90+
'item': 'md-search-result__item'
91+
},
92+
templates: {
93+
item: (hit, { html, components }) => {
94+
return html`
95+
<a href="${hit.abs_url}" class="md-search-result__link" tabindex="-1">
96+
<div class="md-search-result__article md-typeset">
97+
<div class="md-search-result__icon md-icon"></div>
98+
<h1>${components.Highlight({ attribute: 'title', hit })}</h1>
99+
<article>${components.Snippet({ attribute: 'content', hit })} </article>
100+
</div>
101+
</a>`
102+
},
103+
},
104+
})
105+
]);
106+
107+
search.start();

docs/javascripts/search-worker.js

Lines changed: 0 additions & 70 deletions
This file was deleted.

mkdocs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ extra_css:
121121
# used for analytics
122122
extra_javascript:
123123
- "/flarelytics/client.js"
124+
- "https://cdn.jsdelivr.net/npm/[email protected]/dist/lite/builds/browser.umd.js"
125+
- "https://cdn.jsdelivr.net/npm/[email protected]/dist/instantsearch.production.min.js"
126+
- "/javascripts/algolia-search.js"
124127

125128
markdown_extensions:
126129
- tables

0 commit comments

Comments
 (0)