Skip to content

Commit a5a0188

Browse files
committed
Integrate experimental in-browser search #1165
class typ -> class type 2d494bb
1 parent 51ac0c1 commit a5a0188

File tree

10 files changed

+374
-12
lines changed

10 files changed

+374
-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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#in-package-search-results ol {
2+
@apply m-0 p-0 list-none;
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 .search-entry:hover {
19+
text-decoration: none;
20+
background: #f4f4f4;
21+
}
22+
23+
@media (min-width: 64em) {
24+
#in-package-search-results .search-entry {
25+
@apply flex-row;
26+
}
27+
28+
#in-package-search-results .search-entry .entry-title {
29+
@apply shrink-0 w-1/2;
30+
}
31+
}
32+
33+
#in-package-search-results .search-entry .entry-kind {
34+
@apply py-0.5 px-1 bg-body-600 text-white rounded mr-2 text-sm;
35+
}
36+
37+
#in-package-search-results .search-entry .entry-kind {
38+
background: var(--entry-color);
39+
}
40+
41+
#in-package-search-results .search-entry.mod {
42+
--entry-color: rgb(204, 78, 12);
43+
}
44+
45+
#in-package-search-results .search-entry.mty {
46+
--entry-color: #027491;
47+
}
48+
49+
#in-package-search-results .search-entry.mtd {
50+
--entry-color: #327d8e;
51+
}
52+
53+
#in-package-search-results .search-entry.typ {
54+
--entry-color: rgb(42, 84, 183);
55+
}
56+
57+
#in-package-search-results .search-entry.val {
58+
--entry-color: green;
59+
}
60+
61+
#in-package-search-results .search-entry.par {
62+
--entry-color: green;
63+
}
64+
65+
#in-package-search-results .search-entry.cls {
66+
--entry-color: rgb(163, 34, 34);
67+
}
68+
69+
#in-package-search-results .search-entry.cty {
70+
--entry-color: rgb(32, 68, 165);
71+
}
72+
73+
#in-package-search-results .search-entry.exc {
74+
--entry-color: #370c62;
75+
}
76+
77+
#in-package-search-results .search-entry.man {
78+
--entry-color: #47424b;
79+
}
80+
81+
#in-package-search-results .search-entry .entry-title {
82+
overflow-wrap: anywhere;
83+
}
84+
85+
#in-package-search-results .search-entry .entry-name {
86+
@apply font-bold;
87+
color: var(--entry-color);
88+
}
89+
90+
#in-package-search-results .search-entry .prefix-name {
91+
@apply font-semibold;
92+
}
93+
94+
#in-package-search-results .search-entry .entry-comment {
95+
@apply whitespace-nowrap overflow-hidden text-ellipsis;
96+
}
97+
98+
#in-package-search-results .active {
99+
@apply bg-primary-100;
100+
}
101+
102+
103+
#in-package-search #in-package-search-results {
104+
@apply hidden;
105+
}
106+
107+
#in-package-search:focus-within #in-package-search-results {
108+
@apply block;
109+
}

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: 180 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">
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,166 @@ 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 perform_search() {
116+
let q = document.getElementById("in-package-search-input").value;
117+
results = miniSearch.search(q, {
118+
fields: ['name', 'prefixname', 'comment'],
119+
prefix: true,
120+
boost: {
121+
name: 6,
122+
prefixname: 2.5,
123+
comment: 0.8,
124+
},
125+
fuzzy: 0.15,
126+
}).slice(0,50);
127+
let container = document.getElementById("in-package-search-results");
128+
container.innerHTML = "";
129+
130+
let search_results = document.createElement("ol");
131+
132+
results.map((entry) => {
133+
let kind = document.createElement("tt");
134+
kind.innerText = entry.kind
135+
.replace("module type", "mty").replace("root", "mod").replace("module", "mod")
136+
.replace("method", "mtd").replace("class type", "cty").replace("class", "cls")
137+
.replace("core type", "typ").replace("type", "typ")
138+
.replace("exception", "exc").replace("core exception", "exc")
139+
.replace("parameter", "par")
140+
.replace("leaf page", "man").replace("page", "man");
141+
kind.title = entry.kind;
142+
kind.classList.add("entry-kind");
143+
144+
let list_item = document.createElement("li");
145+
let a = document.createElement("a");
146+
a.href = "/" + entry.url;
147+
a.id = "search-result-"+entry.id;
148+
a.classList.add("search-entry", kind.innerText.slice(0,3));
149+
let title = document.createElement("div");
150+
title.classList.add("entry-title");
151+
152+
let prefixname = document.createElement("tt");
153+
prefixname.innerText = entry.prefixname.split(".").reverse().join(".") + (entry.prefixname != "" ? ".": "");
154+
prefixname.classList.add("prefix-name");
155+
let name = document.createElement("tt");
156+
name.classList.add("entry-name");
157+
name.innerText = entry.name;
158+
159+
title.appendChild(kind);
160+
title.appendChild(prefixname);
161+
title.appendChild(name);
162+
163+
let comment = document.createElement("div");
164+
comment.innerText = entry.comment;
165+
comment.classList.add("entry-comment");
166+
167+
a.appendChild(title);
168+
a.appendChild(comment);
169+
170+
list_item.appendChild(a);
171+
172+
search_results.appendChild(list_item);
173+
});
174+
container.appendChild(search_results);
175+
176+
search_results_position = null;
177+
}
178+
179+
function init_search() {
180+
documents = documents.map((d, i) => {return {...d, id: i}});
181+
182+
miniSearch = new MiniSearch({
183+
fields: ['name', 'prefixname', 'comment'],
184+
storeFields: ['name', 'prefixname', 'kind', 'url', 'comment'],
185+
});
186+
187+
miniSearch.addAll(documents);
188+
189+
document.getElementById("in-package-search-input").addEventListener("input", perform_search);
190+
perform_search();
191+
}
192+
193+
function user_interacts() {
194+
let scriptTag = document.createElement("script");
195+
scriptTag.src = "<%s Url.Package.search_index ?version:(Package.url_version package) package.name ~digest %>";
196+
scriptTag.addEventListener("load", init_search);
197+
document.body.appendChild(scriptTag);
198+
199+
document.getElementById("in-package-search-input").removeEventListener("focus", user_interacts);
200+
}
201+
202+
document.getElementById("in-package-search-input").addEventListener("focus", user_interacts);
203+
204+
205+
let search_results_position = null;
206+
function adjust_position(event) {
207+
if (results.length == 0) return;
208+
209+
if (event.key == "ArrowDown") {
210+
if (search_results_position === null) {
211+
search_results_position = 0;
212+
return;
213+
}
214+
if (search_results_position < results.length - 1) {
215+
search_results_position++;
216+
return;
217+
}
218+
} else if (event.key == "ArrowUp") {
219+
if (search_results_position === null) return;
220+
221+
if (search_results_position == 0) {
222+
search_results_position = null;
223+
return;
224+
}
225+
226+
search_results_position--;
227+
return;
228+
}
229+
}
230+
231+
function keydown(event) {
232+
event.stopPropagation();
233+
if (event.key == "Enter") {
234+
if (results.length > 0) {
235+
let url = "/"+results[search_results_position || 0].url;
236+
console.log("going to", url);
237+
window.location = url;
238+
239+
if (url.indexOf("#") != -1) {
240+
let id = url.split("#")[1]
241+
let el = document.getElementById(id);
242+
console.log("id el", id, el);
243+
if (el) setTimeout(() => {
244+
console.log("scroll"); el.scrollIntoView();}, 10);
245+
}
246+
247+
document.getElementById("in-package-search-input").value = "";
248+
results = [];
249+
search_results_position = null;
250+
}
251+
return false;
252+
}
253+
254+
if (search_results_position !== null) document.getElementById("search-result-"+results[search_results_position].id).classList.remove("active");
255+
adjust_position(event);
256+
if (search_results_position !== null) {
257+
let el = document.getElementById("search-result-"+results[search_results_position].id);
258+
el.classList.add("active");
259+
el.scrollIntoView({block: "end"});
260+
}
261+
}
262+
263+
document.getElementById("in-package-search-input").addEventListener("keydown", keydown);
264+
}
265+
</script>
266+
<% | _ -> () ); %>

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)