Skip to content

Commit a367597

Browse files
committed
Integrate experimental in-browser search #1165
@sabine only collapse docstring on md+ screens bfbf2e6
1 parent 5d7afbf commit a367597

File tree

10 files changed

+377
-12
lines changed

10 files changed

+377
-12
lines changed

LICENSE-3RD-PARTY

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ URL: https://swiperjs.com/
112112
License: MIT License
113113
Copyright: Copyright 2014-2021 Vladimir Kharlampidi
114114

115+
asset/vendors/minisearch.min.js:
116+
117+
Name: Minisearch
118+
Version: 6.0.1
119+
URL: https://github.com/lucaong/minisearch
120+
License: MIT License
121+
Copyright: Copyright 2022 Luca Ongaro
122+
115123
--------------------------------------------------------------------------
116124
Licenses
117125
--------------------------------------------------------------------------

asset/vendors/minisearch.min.js

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/global/url.ml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ module Package : sig
1212

1313
val file :
1414
?hash:string -> ?version:string -> filepath:string -> string -> string
15+
16+
val search_index : ?version:string -> digest:string -> string -> string
1517
end = struct
1618
let with_hash = Option.fold ~none:"/p" ~some:(( ^ ) "/u/")
1719
let with_version = Option.fold ~none:"/latest" ~some:(( ^ ) "/")
@@ -25,6 +27,7 @@ end = struct
2527
base ?hash ?version ("/doc/" ^ page)
2628

2729
let file ?hash ?version ~filepath = base ?hash ?version ("/" ^ filepath)
30+
let search_index ?version ~digest = base ?version ("/search-index/" ^ digest)
2831
end
2932

3033
let sitemap = "/sitemap.xml"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#in-package-search-results ol {
2+
@apply m-0 p-0 list-none text-sm;
3+
}
4+
5+
#in-package-search-results li {
6+
@apply m-0 p-0 border-b;
7+
}
8+
9+
#in-package-search-results br {
10+
@apply hidden;
11+
}
12+
13+
#in-package-search-results .search-entry {
14+
@apply px-4 py-1 font-normal text-body-600 flex flex-col;
15+
--entry-color: gray;
16+
}
17+
18+
#in-package-search-results li:first-child .search-entry {
19+
@apply pt-2;
20+
}
21+
22+
#in-package-search-results li:last-child .search-entry {
23+
@apply pb-2;
24+
}
25+
26+
#in-package-search-results .search-entry:hover {
27+
text-decoration: none;
28+
background: #f4f4f4;
29+
}
30+
31+
/*
32+
@media (min-width: 64em) {
33+
#in-package-search-results .search-entry {
34+
@apply flex-row;
35+
}
36+
37+
#in-package-search-results .search-entry .entry-title {
38+
@apply shrink-0 w-1/2;
39+
}
40+
}
41+
*/
42+
43+
#in-package-search-results .search-entry .entry-kind {
44+
@apply py-0.5 px-1 bg-body-600 text-white rounded mr-2 text-sm;
45+
}
46+
47+
#in-package-search-results .search-entry .entry-kind {
48+
background: var(--entry-color);
49+
}
50+
51+
#in-package-search-results .search-entry.mod {
52+
--entry-color: rgb(204, 78, 12);
53+
}
54+
55+
#in-package-search-results .search-entry.mty {
56+
--entry-color: #027491;
57+
}
58+
59+
#in-package-search-results .search-entry.mtd {
60+
--entry-color: #327d8e;
61+
}
62+
63+
#in-package-search-results .search-entry.typ {
64+
--entry-color: rgb(42, 84, 183);
65+
}
66+
67+
#in-package-search-results .search-entry.val {
68+
--entry-color: green;
69+
}
70+
71+
#in-package-search-results .search-entry.par {
72+
--entry-color: green;
73+
}
74+
75+
#in-package-search-results .search-entry.cls {
76+
--entry-color: rgb(163, 34, 34);
77+
}
78+
79+
#in-package-search-results .search-entry.cty {
80+
--entry-color: rgb(32, 68, 165);
81+
}
82+
83+
#in-package-search-results .search-entry.exc {
84+
--entry-color: #370c62;
85+
}
86+
87+
#in-package-search-results .search-entry.man {
88+
--entry-color: #47424b;
89+
}
90+
91+
#in-package-search-results .search-entry .entry-title {
92+
overflow-wrap: anywhere;
93+
}
94+
95+
#in-package-search-results .search-entry .entry-name {
96+
@apply font-bold;
97+
color: var(--entry-color);
98+
}
99+
100+
#in-package-search-results .search-entry .prefix-name {
101+
@apply font-semibold;
102+
}
103+
104+
@media (min-width: 40em) {
105+
#in-package-search-results .search-entry:not(.active):not(.open) .entry-comment {
106+
@apply whitespace-nowrap overflow-hidden text-ellipsis;
107+
}
108+
}
109+
110+
#in-package-search-results .active {
111+
@apply bg-primary-100;
112+
}
113+
114+
#in-package-search #in-package-search-results {
115+
@apply hidden;
116+
}
117+
118+
#in-package-search:focus-within #in-package-search-results {
119+
@apply block;
120+
}

src/ocamlorg_frontend/css/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
@import "./partials/cards.css";
77
@import "./partials/forms.css";
88
@import "./partials/grid_logos.css";
9+
@import "./partials/in_package_search.css";
910
@import "./partials/search.css";
1011
@import "./partials/package_breadcrumbs.css";
1112
@import "./partials/shadows.css";

src/ocamlorg_frontend/layouts/package_layout.eml

Lines changed: 172 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ let render
88
?page
99
~(package : Package.package)
1010
~(documentation_status: Package.documentation_status)
11+
~(search_index_digest: string option)
1112
~left_sidebar_html
1213
~right_sidebar_html
1314
inner =
@@ -60,13 +61,21 @@ Layout.render
6061
</li>
6162
</ol>
6263

63-
<div title="Sorry, in-package search is not yet implemented, but this is where it's going to appear." class="flex w-full items-center">
64-
<input disabled type="search" name="q" class="focus:border-gray-800 text-gray-800 bg-gray-300 border-gray-300 h-10 rounded-l-md appearance-none px-4 flex-grow"
65-
autocomplete="off"
66-
placeholder="Sorry, in-package search is not yet implemented, but this is where it's going to appear :-)">
67-
<button disabled aria-label="search" class="h-10 rounded-r-md bg-gray-300 text-gray-800 flex items-center justify-center px-4">
68-
<%s! Icons.magnifying_glass "w-6 h-6" %>
69-
</button>
64+
<div id="in-package-search" class="relative w-full">
65+
<div class="flex w-full items-center">
66+
<% if Option.is_some search_index_digest then (%>
67+
<input id="in-package-search-input" type="search" name="q" class="focus:border-gray-800 text-gray-800 border-primary-600 h-10 rounded-l-md appearance-none px-4 flex-grow"
68+
autocomplete="off"
69+
placeholder="Search names in this package..."
70+
>
71+
<div aria-hidden="true" class="h-10 rounded-r-md bg-primary-600 text-white flex items-center justify-center px-4">
72+
<%s! Icons.magnifying_glass "w-6 h-6" %>
73+
</div>
74+
<% ); %>
75+
</div>
76+
77+
<div id="in-package-search-results" class="absolute top-12 right-0 left-0 bg-white z-20 w-full max-h-[60vh] overflow-y-auto border rounded-lg shadow-xl">
78+
</div>
7079
</div>
7180
</div>
7281

@@ -78,6 +87,7 @@ Layout.render
7887
x-transition:leave-end="-translate-x-full">
7988
<%s! left_sidebar_html %>
8089
</div>
90+
8191
<div class="flex-1 z-0 z- min-w-0 pt-6 pb-12 md:pb-[70vh]">
8292
<%s! inner %>
8393
</div>
@@ -91,4 +101,158 @@ Layout.render
91101
</div>
92102
</div>
93103
</div>
94-
<%s! Toc.script %>
104+
<%s! Toc.script %>
105+
<% (match search_index_digest with | Some(digest) -> %>
106+
<script src="<%s Ocamlorg_static.Asset. url "vendors/minisearch.min.js" %>"></script>
107+
<script>
108+
function Fuse() {}
109+
</script>
110+
<script>
111+
{
112+
let miniSearch;
113+
let results = [];
114+
115+
function shortness_factor(r) {
116+
return 1 + 2*Math.log(1 + 1/(r.prefixname.length + r.name.length));
117+
}
118+
119+
function perform_search() {
120+
let q = document.getElementById("in-package-search-input").value;
121+
results = miniSearch.search(q, {
122+
fields: ['name', 'prefixname', 'comment'],
123+
prefix: true,
124+
boost: {
125+
name: 6,
126+
prefixname: 2.5,
127+
comment: 0.8,
128+
},
129+
fuzzy: 0.15,
130+
}).slice(0,50);
131+
results = results.map(r => {return {...r, score: r.score * shortness_factor(r), shortness: shortness_factor(r)}}).sort((r1,r2) => r2.score - r1.score);
132+
let container = document.getElementById("in-package-search-results");
133+
container.innerHTML = "";
134+
135+
let search_results = document.createElement("ol");
136+
137+
results.map((entry) => {
138+
let kind = document.createElement("tt");
139+
kind.innerText = entry.kind
140+
.replace("module type", "mty").replace("root", "mod").replace("module", "mod")
141+
.replace("method", "mtd").replace("class type", "cty").replace("class", "cls")
142+
.replace("core type", "typ").replace("type", "typ")
143+
.replace("exception", "exc").replace("core exception", "exc")
144+
.replace("parameter", "par")
145+
.replace("leaf page", "man").replace("page", "man");
146+
kind.title = entry.kind;
147+
kind.classList.add("entry-kind");
148+
149+
let list_item = document.createElement("li");
150+
let a = document.createElement("a");
151+
a.href = "/" + entry.url;
152+
a.id = "search-result-"+entry.id;
153+
a.classList.add("search-entry", kind.innerText.slice(0,3));
154+
let title = document.createElement("div");
155+
title.classList.add("entry-title");
156+
157+
let prefixname = document.createElement("tt");
158+
prefixname.innerText = entry.prefixname.split(".").reverse().join(".") + (entry.prefixname != "" ? ".": "");
159+
prefixname.classList.add("prefix-name");
160+
let name = document.createElement("tt");
161+
name.classList.add("entry-name");
162+
name.innerText = entry.name;
163+
164+
title.appendChild(kind);
165+
title.appendChild(prefixname);
166+
title.appendChild(name);
167+
168+
let comment = document.createElement("div");
169+
comment.innerText = entry.comment;
170+
comment.classList.add("entry-comment");
171+
172+
a.appendChild(title);
173+
a.appendChild(comment);
174+
175+
list_item.appendChild(a);
176+
177+
search_results.appendChild(list_item);
178+
});
179+
container.appendChild(search_results);
180+
181+
search_results_position = null;
182+
}
183+
184+
function init_search() {
185+
documents = documents.map((d, i) => {return {...d, id: i}});
186+
187+
miniSearch = new MiniSearch({
188+
fields: ['name', 'prefixname', 'comment'],
189+
storeFields: ['name', 'prefixname', 'kind', 'url', 'comment'],
190+
});
191+
192+
miniSearch.addAll(documents);
193+
194+
document.getElementById("in-package-search-input").addEventListener("input", perform_search);
195+
perform_search();
196+
}
197+
198+
function user_interacts() {
199+
let scriptTag = document.createElement("script");
200+
scriptTag.src = "<%s Url.Package.search_index ?version:(Package.url_version package) package.name ~digest %>";
201+
scriptTag.addEventListener("load", init_search);
202+
document.body.appendChild(scriptTag);
203+
204+
document.getElementById("in-package-search-input").removeEventListener("focus", user_interacts);
205+
}
206+
207+
document.getElementById("in-package-search-input").addEventListener("focus", user_interacts);
208+
209+
210+
let search_results_position = null;
211+
function adjust_position(event) {
212+
if (results.length == 0) return;
213+
214+
if (event.key == "ArrowDown") {
215+
if (search_results_position === null) {
216+
search_results_position = 0;
217+
return;
218+
}
219+
if (search_results_position < results.length - 1) {
220+
search_results_position++;
221+
return;
222+
}
223+
} else if (event.key == "ArrowUp") {
224+
if (search_results_position === null) return;
225+
226+
if (search_results_position == 0) {
227+
search_results_position = null;
228+
return;
229+
}
230+
231+
search_results_position--;
232+
return;
233+
}
234+
}
235+
236+
function keydown(event) {
237+
event.stopPropagation();
238+
if (event.key == "Enter") {
239+
if (results.length > 0) {
240+
let url = "/"+results[search_results_position || 0].url;
241+
window.location = url;
242+
}
243+
return false;
244+
}
245+
246+
if (search_results_position !== null) document.getElementById("search-result-"+results[search_results_position].id).classList.remove("active");
247+
adjust_position(event);
248+
if (search_results_position !== null) {
249+
let el = document.getElementById("search-result-"+results[search_results_position].id);
250+
el.classList.add("active");
251+
el.scrollIntoView({block: "end"});
252+
}
253+
}
254+
255+
document.getElementById("in-package-search-input").addEventListener("keydown", keydown);
256+
}
257+
</script>
258+
<% | _ -> () ); %>

src/ocamlorg_package/lib/ocamlorg_package.ml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,19 @@ let file ~kind t path =
351351
let url = package_url ^ path ^ ".json" in
352352
odoc_page ~url
353353

354+
let search_index ~kind t =
355+
let package_url =
356+
package_url ~kind (Name.to_string t.name) (Version.to_string t.version)
357+
in
358+
let url = package_url ^ "index.js" in
359+
let open Lwt.Syntax in
360+
let* content = http_get url in
361+
match content with
362+
| Ok content -> Lwt.return (Some content)
363+
| Error _ ->
364+
Logs.info (fun m -> m "Failed to fetch search index at %s" url);
365+
Lwt.return None
366+
354367
let maybe_file ~kind t filename =
355368
let open Lwt.Syntax in
356369
let+ doc = file ~kind t filename in

src/ocamlorg_package/lib/ocamlorg_package.mli

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ val file :
146146
(** Get the rendered content of an HTML page for a file accompanying a package
147147
given its URL relative to the root of the package. *)
148148

149+
val search_index :
150+
kind:[< `Package | `Universe of string ] -> t -> string option Lwt.t
151+
(** Retrieve the search index of a given package. *)
152+
149153
val init : ?disable_polling:bool -> unit -> state
150154
(** [init ()] initialises the opam-repository state. By default
151155
[disable_polling] is set to [false], but can be disabled for tests. *)

0 commit comments

Comments
 (0)