hematite 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +58 -0
- data/_config.yml +33 -0
- data/_data/strings/en.yml +32 -0
- data/_data/strings/es.yml +33 -0
- data/_includes/img/hamburger_menu.svg +78 -0
- data/_includes/img/search_icon.svg +99 -0
- data/_includes/katex_includes.html +26 -0
- data/_includes/nav/page_navigation.html +10 -0
- data/_includes/nav/pages_list.html +17 -0
- data/_includes/nav/pinned_page.html +12 -0
- data/_includes/nav/sidebar.html +25 -0
- data/_layouts/calendar.html +36 -0
- data/_layouts/default.html +31 -0
- data/_layouts/page.html +5 -0
- data/_layouts/post.html +42 -0
- data/_sass/_animations.scss +16 -0
- data/_sass/_calendar.scss +63 -0
- data/_sass/_colors.scss +73 -0
- data/_sass/_elements.scss +125 -0
- data/_sass/_layout.scss +224 -0
- data/_sass/_nav.scss +180 -0
- data/_sass/_rogue.scss +50 -0
- data/_sass/_sizes.scss +18 -0
- data/_sass/hematite.scss +10 -0
- data/assets/html/all_tags.html +26 -0
- data/assets/img/favicon.svg +12 -0
- data/assets/js/AnimationUtil.mjs +72 -0
- data/assets/js/AsyncUtil.mjs +18 -0
- data/assets/js/DateUtil.mjs +123 -0
- data/assets/js/PageAlert.mjs +143 -0
- data/assets/js/UrlHelper.mjs +118 -0
- data/assets/js/assertions.mjs +9 -0
- data/assets/js/dropdownExpander.mjs +78 -0
- data/assets/js/layout/calendar.mjs +478 -0
- data/assets/js/layout/post.mjs +65 -0
- data/assets/js/linkButtonGenerator.mjs +45 -0
- data/assets/js/main.mjs +19 -0
- data/assets/js/search.mjs +358 -0
- data/assets/js/sidebar.mjs +97 -0
- data/assets/js/string_data.mjs +19 -0
- data/assets/js/strings.mjs +167 -0
- data/assets/plugin/katex/README.md +119 -0
- data/assets/plugin/katex/contrib/auto-render.min.js +1 -0
- data/assets/plugin/katex/contrib/copy-tex.min.css +1 -0
- data/assets/plugin/katex/contrib/copy-tex.min.js +1 -0
- data/assets/plugin/katex/contrib/mathtex-script-type.min.js +1 -0
- data/assets/plugin/katex/contrib/mhchem.min.js +1 -0
- data/assets/plugin/katex/contrib/render-a11y-string.min.js +1 -0
- data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/assets/plugin/katex/katex.min.css +1 -0
- data/assets/plugin/katex/katex.min.js +1 -0
- data/assets/search_data.json +36 -0
- data/assets/style.scss +15 -0
- metadata +170 -0
@@ -0,0 +1,358 @@
|
|
1
|
+
---
|
2
|
+
permalink: assets/js/search.mjs
|
3
|
+
---
|
4
|
+
|
5
|
+
import { stringLookup } from "./strings.mjs";
|
6
|
+
import { expandContainingDropdowns } from "./dropdownExpander.mjs";
|
7
|
+
import AnimationUtil from "./AnimationUtil.mjs";
|
8
|
+
import AsyncUtil from "./AsyncUtil.mjs";
|
9
|
+
import UrlHelper from "./UrlHelper.mjs";
|
10
|
+
|
11
|
+
const PAGE_DATA_URL = `{{ "/assets/search_data.json" | relative_url }}`;
|
12
|
+
const MATCHING_TITLE_PRIORITY_INCREMENT = 15;
|
13
|
+
const ALREADY_SEEN_PRIORITY_DECREMENT = 100;
|
14
|
+
const SEARCH_CONTEXT_LEN = 40;
|
15
|
+
|
16
|
+
/// Handles fetching page data, caching it, etc.
|
17
|
+
class Searcher {
|
18
|
+
constructor() {
|
19
|
+
this.cachedData_ = null;
|
20
|
+
}
|
21
|
+
|
22
|
+
async getPageData_() {
|
23
|
+
if (this.cachedData_ == null) {
|
24
|
+
let response = await fetch(PAGE_DATA_URL);
|
25
|
+
|
26
|
+
// If the server responded, but something went wrong,
|
27
|
+
if (!response.ok) {
|
28
|
+
throw new Error(
|
29
|
+
`While fetching search data from “${PAGE_DATA_URL}”:` +
|
30
|
+
` Server responded with code ${response.status}/${response.statusText}.`
|
31
|
+
);
|
32
|
+
}
|
33
|
+
|
34
|
+
let json = await (response).json();
|
35
|
+
this.cachedData_ = json;
|
36
|
+
}
|
37
|
+
|
38
|
+
return this.cachedData_;
|
39
|
+
}
|
40
|
+
|
41
|
+
filterContent_(content) {
|
42
|
+
return content
|
43
|
+
// Remove tags that have attributes < 20 characters long (total)
|
44
|
+
.replaceAll(/[<][/]?\w+\s*[^>]{0,20}[>]/g, "")
|
45
|
+
// Remove tags with known attributes
|
46
|
+
.replaceAll(/[<]\w+(?:\s+(?:href|class|id|src)\s*[=]\s*["'].*['"])+[>]/g, "")
|
47
|
+
// Replace escape sequences
|
48
|
+
.replaceAll(/[&]lt;/g, "<")
|
49
|
+
.replaceAll(/[&]gt;/g, ">")
|
50
|
+
.replaceAll(/[&]ldquo;/g, '"')
|
51
|
+
.replaceAll(/[&]rdquo;/g, '"')
|
52
|
+
.replaceAll(/[&]amp;/g, "&")
|
53
|
+
.replaceAll(/\s+/g, ' ');
|
54
|
+
|
55
|
+
}
|
56
|
+
|
57
|
+
/// Get the container element for the [n]th search
|
58
|
+
/// result for [query] in the given [elem].
|
59
|
+
/// Returns an Element.
|
60
|
+
getNthResultIn(elem, query, n) {
|
61
|
+
var recurse = (elem) => {
|
62
|
+
let isElement = elem.tagName != undefined;
|
63
|
+
|
64
|
+
if (n < 0) {
|
65
|
+
return null;
|
66
|
+
}
|
67
|
+
|
68
|
+
// The element must actually contain the query.
|
69
|
+
// [elem] may not be an Element (we only require that it be a Node),
|
70
|
+
// so use textContent.
|
71
|
+
let searchText = this.filterContent_(isElement ? elem.innerHTML : elem.textContent);
|
72
|
+
searchText = searchText.toLowerCase();
|
73
|
+
|
74
|
+
if (searchText.indexOf(query) == -1) {
|
75
|
+
return null;
|
76
|
+
}
|
77
|
+
|
78
|
+
// If the current node is a leaf,
|
79
|
+
if (elem.childNodes.length == 0) {
|
80
|
+
let numMatches = searchText.split(query).length - 1;
|
81
|
+
n -= numMatches;
|
82
|
+
|
83
|
+
// If we've considered enough matches,
|
84
|
+
if (n < 0) {
|
85
|
+
return elem;
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
for (const child of elem.childNodes) {
|
90
|
+
let res = recurse(child);
|
91
|
+
|
92
|
+
if (res) {
|
93
|
+
let resIsElement = res.tagName != undefined;
|
94
|
+
|
95
|
+
if (isElement && !resIsElement) {
|
96
|
+
return elem;
|
97
|
+
}
|
98
|
+
return res;
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
return null;
|
103
|
+
};
|
104
|
+
|
105
|
+
return recurse(elem);
|
106
|
+
}
|
107
|
+
|
108
|
+
async runSearch(query) {
|
109
|
+
let data = await this.getPageData_();
|
110
|
+
let results = [];
|
111
|
+
|
112
|
+
query = query
|
113
|
+
.replaceAll(/\s+/g, ' ')
|
114
|
+
.toLowerCase();
|
115
|
+
|
116
|
+
for (const page of data) {
|
117
|
+
// Remove HTML tags.
|
118
|
+
let content = this.filterContent_(page.content);
|
119
|
+
content += '\n' + page.title;
|
120
|
+
|
121
|
+
let pageData = {
|
122
|
+
title: page.title,
|
123
|
+
url: page.url,
|
124
|
+
numMatches: 0,
|
125
|
+
titleMatches: (page.title.toLowerCase().indexOf(query) != -1)
|
126
|
+
};
|
127
|
+
|
128
|
+
|
129
|
+
// TODO: Improve search!
|
130
|
+
let toSearch = content.toLowerCase();
|
131
|
+
let matchLoc = toSearch.indexOf(query);
|
132
|
+
let startPos = 0;
|
133
|
+
let index = 0;
|
134
|
+
while (matchLoc !== -1 && startPos < toSearch.length) {
|
135
|
+
let context = content.substring(
|
136
|
+
matchLoc - SEARCH_CONTEXT_LEN,
|
137
|
+
matchLoc + SEARCH_CONTEXT_LEN
|
138
|
+
);
|
139
|
+
|
140
|
+
// If content was clipped,
|
141
|
+
if (matchLoc - SEARCH_CONTEXT_LEN > 0) {
|
142
|
+
context = '…' + context;
|
143
|
+
}
|
144
|
+
if (matchLoc + SEARCH_CONTEXT_LEN < content.length) {
|
145
|
+
context += '…';
|
146
|
+
}
|
147
|
+
|
148
|
+
results.push({
|
149
|
+
index,
|
150
|
+
context,
|
151
|
+
pageData,
|
152
|
+
});
|
153
|
+
|
154
|
+
index ++;
|
155
|
+
pageData.numMatches ++;
|
156
|
+
startPos = matchLoc + query.length;
|
157
|
+
matchLoc = toSearch.indexOf(query, startPos);
|
158
|
+
}
|
159
|
+
}
|
160
|
+
|
161
|
+
// Prioritize results
|
162
|
+
let includedPages = {};
|
163
|
+
for (let i = 0; i < results.length; i++) {
|
164
|
+
let result = results[i];
|
165
|
+
|
166
|
+
result.priority = result.pageData.numMatches;
|
167
|
+
|
168
|
+
if (result.pageData.titleMatches) {
|
169
|
+
result.priority += MATCHING_TITLE_PRIORITY_INCREMENT;
|
170
|
+
}
|
171
|
+
|
172
|
+
// If we already are including a copy of the page, this result
|
173
|
+
// is just for another match in the same page. Deprioritize
|
174
|
+
if (includedPages[result.pageData.title]) {
|
175
|
+
result.priority -= ALREADY_SEEN_PRIORITY_DECREMENT;
|
176
|
+
}
|
177
|
+
includedPages[result.pageData.title] = true;
|
178
|
+
}
|
179
|
+
|
180
|
+
results.sort((a, b) => {
|
181
|
+
return b.priority - a.priority;
|
182
|
+
});
|
183
|
+
|
184
|
+
return results;
|
185
|
+
}
|
186
|
+
}
|
187
|
+
|
188
|
+
/// Scrolls to and shows the search result for the given [query]
|
189
|
+
/// If neither [query] nor [resultIndex] are given, attempt to get them from
|
190
|
+
/// the page's arguments (i.e. from https://example.com/...?search=...,n=...).
|
191
|
+
function focusSearchResult(searcher, elem, query, resultIndex) {
|
192
|
+
if (query === undefined && resultIndex === undefined) {
|
193
|
+
let urlArgs = UrlHelper.getPageArgs();
|
194
|
+
let pageHash = UrlHelper.getPageHash();
|
195
|
+
|
196
|
+
if (urlArgs === null) {
|
197
|
+
return;
|
198
|
+
}
|
199
|
+
|
200
|
+
// The page's hash also causes scrolling. Don't focus
|
201
|
+
// if the page has a hash.
|
202
|
+
if (pageHash != null) {
|
203
|
+
return;
|
204
|
+
}
|
205
|
+
|
206
|
+
query = urlArgs.query;
|
207
|
+
resultIndex = parseInt(urlArgs.index);
|
208
|
+
|
209
|
+
if (isNaN(resultIndex)) {
|
210
|
+
console.warn("Unable to navigate to result. Given idx is NaN");
|
211
|
+
return;
|
212
|
+
}
|
213
|
+
}
|
214
|
+
|
215
|
+
resultIndex ??= 0;
|
216
|
+
|
217
|
+
if (query === undefined) {
|
218
|
+
return;
|
219
|
+
}
|
220
|
+
|
221
|
+
let result = searcher.getNthResultIn(elem, query, resultIndex);
|
222
|
+
if (result) {
|
223
|
+
console.log("Scrolling", result, "into view...");
|
224
|
+
|
225
|
+
expandContainingDropdowns(result);
|
226
|
+
|
227
|
+
result.focus();
|
228
|
+
result.scrollIntoView();
|
229
|
+
}
|
230
|
+
}
|
231
|
+
|
232
|
+
/// Set up inputs/events using the given [searcher]. If [null], a new
|
233
|
+
/// [Searcher] is created.
|
234
|
+
function handleSearch(searcher) {
|
235
|
+
const searchInput = document.querySelector(".search-container > #search_input");
|
236
|
+
const searchBtn = document.querySelector(".search-container > #search_btn");
|
237
|
+
const searchResults = document.querySelector("#sidebar .search-results");
|
238
|
+
|
239
|
+
searchInput.setAttribute("placeholder", stringLookup(`search_site_placeholder`));
|
240
|
+
searchBtn.disabled = true;
|
241
|
+
|
242
|
+
searcher ??= new Searcher();
|
243
|
+
focusSearchResult(searcher, document.querySelector("main"));
|
244
|
+
|
245
|
+
const showResults = (query, results) => {
|
246
|
+
let descriptionElem = document.createElement("div");
|
247
|
+
descriptionElem.innerText =
|
248
|
+
stringLookup(`found_search_results`, results.length);
|
249
|
+
descriptionElem.classList.add(`results-description`);
|
250
|
+
|
251
|
+
searchResults.replaceChildren(descriptionElem);
|
252
|
+
|
253
|
+
for (const result of results) {
|
254
|
+
let link = document.createElement("a");
|
255
|
+
let context = document.createElement("div");
|
256
|
+
context.classList.add('context');
|
257
|
+
|
258
|
+
link.innerText = result.pageData.title ?? stringLookup(`untitled`);
|
259
|
+
link.href =
|
260
|
+
result.pageData.url + `?query=${escape(query)},index=${result.index}`;
|
261
|
+
context.innerText = result.context;
|
262
|
+
|
263
|
+
link.appendChild(context);
|
264
|
+
searchResults.appendChild(link);
|
265
|
+
}
|
266
|
+
|
267
|
+
AnimationUtil.expandInVert(searchResults);
|
268
|
+
searchResults.classList.remove(`hidden`);
|
269
|
+
|
270
|
+
return { descriptionElem };
|
271
|
+
};
|
272
|
+
|
273
|
+
const showError = (error) => {
|
274
|
+
let errorDescription = document.createElement("div");
|
275
|
+
errorDescription.innerText = stringLookup(`search_error`, `${error}`);
|
276
|
+
|
277
|
+
searchResults.replaceChildren(errorDescription);
|
278
|
+
|
279
|
+
AnimationUtil.expandInVert(searchResults);
|
280
|
+
searchResults.classList.remove(`hidden`);
|
281
|
+
|
282
|
+
return { errorDescription };
|
283
|
+
};
|
284
|
+
|
285
|
+
const hideResults = () => {
|
286
|
+
AnimationUtil.collapseOutVert(searchResults);
|
287
|
+
searchResults.classList.add(`hidden`);
|
288
|
+
};
|
289
|
+
|
290
|
+
const areSearchResultsHidden = () =>
|
291
|
+
searchResults.classList.contains('hidden');
|
292
|
+
|
293
|
+
let lastQuery;
|
294
|
+
const updateSearchBtn = () => {
|
295
|
+
let searchText = searchInput.value;
|
296
|
+
let newLabel = stringLookup(`search_disabled_no_content`);
|
297
|
+
let shouldDisable = (searchText == "");
|
298
|
+
shouldDisable &&= !searchBtn.classList.contains('close_search');
|
299
|
+
|
300
|
+
searchBtn.disabled = shouldDisable;
|
301
|
+
if (!shouldDisable) {
|
302
|
+
newLabel = stringLookup(`search_site_action`, searchText);
|
303
|
+
}
|
304
|
+
|
305
|
+
// The search button's action is to search
|
306
|
+
if (lastQuery == searchText && !areSearchResultsHidden()) {
|
307
|
+
searchBtn.classList.add('close_search');
|
308
|
+
newLabel = stringLookup(`hide_search_results_action`, searchText);
|
309
|
+
}
|
310
|
+
else {
|
311
|
+
searchBtn.classList.remove('close_search');
|
312
|
+
}
|
313
|
+
|
314
|
+
searchBtn.setAttribute("title", newLabel);
|
315
|
+
};
|
316
|
+
|
317
|
+
searchBtn.onclick = async () => {
|
318
|
+
let query = searchInput.value;
|
319
|
+
|
320
|
+
// If the user hasn't changed the input from their last search,
|
321
|
+
// we want to close the search view. Otherwise,
|
322
|
+
if (query != lastQuery || areSearchResultsHidden()) {
|
323
|
+
lastQuery = searchInput.value;
|
324
|
+
|
325
|
+
try {
|
326
|
+
let results = await searcher.runSearch(query);
|
327
|
+
|
328
|
+
showResults(query, results);
|
329
|
+
} catch (e) {
|
330
|
+
console.error(e);
|
331
|
+
|
332
|
+
showError(e);
|
333
|
+
}
|
334
|
+
} else {
|
335
|
+
hideResults();
|
336
|
+
}
|
337
|
+
|
338
|
+
updateSearchBtn();
|
339
|
+
};
|
340
|
+
|
341
|
+
// Enter: causes searching to happen
|
342
|
+
searchInput.addEventListener("keyup", (evt) => {
|
343
|
+
if (evt.key == "Enter") {
|
344
|
+
searchBtn.click();
|
345
|
+
}
|
346
|
+
|
347
|
+
updateSearchBtn();
|
348
|
+
});
|
349
|
+
|
350
|
+
searchInput.addEventListener("input", (evt) => {
|
351
|
+
updateSearchBtn();
|
352
|
+
});
|
353
|
+
|
354
|
+
updateSearchBtn();
|
355
|
+
}
|
356
|
+
|
357
|
+
export default handleSearch;
|
358
|
+
export { handleSearch, Searcher };
|
@@ -0,0 +1,97 @@
|
|
1
|
+
import { stringLookup } from "./strings.mjs";
|
2
|
+
import { announceForAccessibility } from "./PageAlert.mjs";
|
3
|
+
|
4
|
+
function handleSidebar() {
|
5
|
+
const toggleBtn = document.querySelector(`button#toggle_sidebar_btn`);
|
6
|
+
const sidebar = document.querySelector(`nav#sidebar`);
|
7
|
+
const mainContainer = document.querySelector(`.main-container`);
|
8
|
+
|
9
|
+
// True if sidebar has been toggled by user at least once.
|
10
|
+
let sidebarToggled = false;
|
11
|
+
|
12
|
+
const setBtnLabel = (text) => {
|
13
|
+
toggleBtn.setAttribute(`title`, text);
|
14
|
+
};
|
15
|
+
|
16
|
+
const setSidebarOpen = (open) => {
|
17
|
+
if (!open) {
|
18
|
+
sidebar.classList.remove(`open`);
|
19
|
+
document.scrollingElement?.classList.remove(`hasOpenSidebar`);
|
20
|
+
}
|
21
|
+
else {
|
22
|
+
sidebar.classList.add(`open`);
|
23
|
+
document.scrollingElement?.classList.add(`hasOpenSidebar`);
|
24
|
+
}
|
25
|
+
};
|
26
|
+
|
27
|
+
const isSidebarOpen = () => sidebar.classList.contains(`open`);
|
28
|
+
|
29
|
+
const updateBtn = () => {
|
30
|
+
if (sidebar.classList.contains(`open`)) {
|
31
|
+
toggleBtn.classList.add(`close_btn`);
|
32
|
+
setBtnLabel(stringLookup(`close_sidebar`));
|
33
|
+
} else {
|
34
|
+
toggleBtn.classList.remove(`close_btn`);
|
35
|
+
setBtnLabel(stringLookup(`open_sidebar`));
|
36
|
+
}
|
37
|
+
};
|
38
|
+
|
39
|
+
// Expand the sidebar with/without animation.
|
40
|
+
const autoExpandSidebar = (noAnimations) => {
|
41
|
+
if (noAnimations) {
|
42
|
+
// Don't animate the sidebar if auto-expanding.
|
43
|
+
sidebar.style.transition = "none";
|
44
|
+
}
|
45
|
+
|
46
|
+
let remainingSpace = window.innerWidth - mainContainer.clientWidth;
|
47
|
+
let spaceOnLeft = remainingSpace / 2;
|
48
|
+
|
49
|
+
// If there's enough space for the sidebar
|
50
|
+
if (sidebar.clientWidth < spaceOnLeft) {
|
51
|
+
setSidebarOpen(true);
|
52
|
+
} else {
|
53
|
+
setSidebarOpen(false);
|
54
|
+
}
|
55
|
+
|
56
|
+
|
57
|
+
updateBtn();
|
58
|
+
|
59
|
+
if (noAnimations) {
|
60
|
+
// Reset the sidebar's transition.
|
61
|
+
requestAnimationFrame(() => {
|
62
|
+
sidebar.style.transition = "";
|
63
|
+
});
|
64
|
+
}
|
65
|
+
};
|
66
|
+
|
67
|
+
toggleBtn.onclick = () => {
|
68
|
+
setSidebarOpen(!isSidebarOpen());
|
69
|
+
|
70
|
+
sidebarToggled = true;
|
71
|
+
updateBtn();
|
72
|
+
|
73
|
+
if (isSidebarOpen()) {
|
74
|
+
announceForAccessibility(stringLookup(`sidebar_opened_announcement`));
|
75
|
+
}
|
76
|
+
else {
|
77
|
+
announceForAccessibility(stringLookup(`sidebar_closed_announcement`));
|
78
|
+
}
|
79
|
+
};
|
80
|
+
|
81
|
+
updateBtn();
|
82
|
+
|
83
|
+
|
84
|
+
// Auto-set whether the sidebar is open.
|
85
|
+
if (mainContainer) {
|
86
|
+
autoExpandSidebar(true);
|
87
|
+
|
88
|
+
addEventListener('resize', () => {
|
89
|
+
if (!sidebarToggled) {
|
90
|
+
autoExpandSidebar();
|
91
|
+
}
|
92
|
+
});
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
export { handleSidebar };
|
97
|
+
export default handleSidebar;
|
@@ -0,0 +1,19 @@
|
|
1
|
+
---
|
2
|
+
permalink: /assets/js/string_data.mjs
|
3
|
+
---
|
4
|
+
|
5
|
+
const STRING_TABLE = {
|
6
|
+
{% for language in site.data.strings %}
|
7
|
+
{{ language[0] }}: {
|
8
|
+
{% for string in language[1] %}
|
9
|
+
"{{ string[0] }}": {{ string[1] | jsonify }},
|
10
|
+
{% endfor %}
|
11
|
+
},
|
12
|
+
{% endfor %}
|
13
|
+
};
|
14
|
+
|
15
|
+
// Locales to check if a string isn't localized in any of the
|
16
|
+
// user's preferred languages.
|
17
|
+
const DEFAULT_LOCALES = [ 'en' ];
|
18
|
+
|
19
|
+
export { STRING_TABLE, DEFAULT_LOCALES };
|
@@ -0,0 +1,167 @@
|
|
1
|
+
|
2
|
+
import { assertEq } from "./assertions.mjs";
|
3
|
+
import DateUtil from "./DateUtil.mjs";
|
4
|
+
import { STRING_TABLE, DEFAULT_LOCALES } from "./string_data.mjs";
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
/// Returns a possibly-localized string corresponding to [key].
|
9
|
+
function stringLookup(key, ...formatting) {
|
10
|
+
let sourceText = null;
|
11
|
+
|
12
|
+
// Search for a translation in a supported language
|
13
|
+
for (const lang of navigator.languages) {
|
14
|
+
if (STRING_TABLE[lang] && STRING_TABLE[lang][key] != null) {
|
15
|
+
sourceText = STRING_TABLE[lang][key];
|
16
|
+
break;
|
17
|
+
}
|
18
|
+
}
|
19
|
+
|
20
|
+
// If not in any of the supported languages...
|
21
|
+
if (sourceText == null) {
|
22
|
+
for (const lang of DEFAULT_LOCALES) {
|
23
|
+
if (STRING_TABLE[lang][key] != null) {
|
24
|
+
sourceText = STRING_TABLE[lang][key];
|
25
|
+
|
26
|
+
console.warn(
|
27
|
+
`Translation for ${key} not found in any supported language.` +
|
28
|
+
` Substituting ${sourceText} from ${lang}.`
|
29
|
+
);
|
30
|
+
break;
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
34
|
+
}
|
35
|
+
|
36
|
+
if (sourceText) {
|
37
|
+
return formatText(sourceText, ...formatting);
|
38
|
+
}
|
39
|
+
else {
|
40
|
+
console.warn(`String lookup for key “${key}” not found`);
|
41
|
+
return key;
|
42
|
+
}
|
43
|
+
}
|
44
|
+
|
45
|
+
function formatText(text, ...formatArgs) {
|
46
|
+
let chars = text.split('');
|
47
|
+
let escaped = false;
|
48
|
+
let i = 0;
|
49
|
+
let result = [];
|
50
|
+
formatArgs ??= [];
|
51
|
+
|
52
|
+
// Convert each argument to a string.
|
53
|
+
formatArgs = formatArgs.map((elem) => {
|
54
|
+
if (DateUtil.isDate(elem)) {
|
55
|
+
return DateUtil.toString(elem);
|
56
|
+
}
|
57
|
+
|
58
|
+
return "" + elem;
|
59
|
+
});
|
60
|
+
|
61
|
+
var consumeNumber = () => {
|
62
|
+
let numberRes = "";
|
63
|
+
|
64
|
+
for (; !isNaN(parseInt(text.charAt(i))); ++i) {
|
65
|
+
numberRes += text.charAt(i);
|
66
|
+
}
|
67
|
+
|
68
|
+
if (numberRes == "") {
|
69
|
+
return null;
|
70
|
+
}
|
71
|
+
|
72
|
+
return parseInt(numberRes);
|
73
|
+
};
|
74
|
+
|
75
|
+
// Consumes a single format specifier in the form
|
76
|
+
// {n} for some integer n ∈ [0, formatArgs.length).
|
77
|
+
var consumeFormatSpec = () => {
|
78
|
+
let initialPoint = i;
|
79
|
+
if (!escaped && text.charAt(i) == '{') {
|
80
|
+
i++;
|
81
|
+
let formatNumber = consumeNumber();
|
82
|
+
|
83
|
+
// If the number is invalid,
|
84
|
+
if (formatNumber === null
|
85
|
+
|| formatNumber > formatArgs.length
|
86
|
+
|| formatNumber < 0
|
87
|
+
) {
|
88
|
+
// Unconsume and return
|
89
|
+
i = initialPoint;
|
90
|
+
return false;
|
91
|
+
}
|
92
|
+
|
93
|
+
if (text.charAt(i) != '}') {
|
94
|
+
i = initialPoint;
|
95
|
+
return false;
|
96
|
+
}
|
97
|
+
|
98
|
+
i++;
|
99
|
+
|
100
|
+
result.push(formatArgs[formatNumber]);
|
101
|
+
return true;
|
102
|
+
}
|
103
|
+
|
104
|
+
return false;
|
105
|
+
};
|
106
|
+
|
107
|
+
var consumeEscape = () => {
|
108
|
+
if (escaped) {
|
109
|
+
escaped = false;
|
110
|
+
consumeCharacter();
|
111
|
+
|
112
|
+
return true;
|
113
|
+
}
|
114
|
+
|
115
|
+
if (text.charAt(i) == '\\') {
|
116
|
+
i++;
|
117
|
+
escaped = true;
|
118
|
+
|
119
|
+
return true;
|
120
|
+
}
|
121
|
+
|
122
|
+
return false;
|
123
|
+
};
|
124
|
+
|
125
|
+
var consumeCharacter = () => {
|
126
|
+
result.push(text.charAt(i));
|
127
|
+
i++;
|
128
|
+
|
129
|
+
return true;
|
130
|
+
};
|
131
|
+
|
132
|
+
for (i = 0; i < text.length;) {
|
133
|
+
let current = text.charAt(i);
|
134
|
+
|
135
|
+
consumeEscape() || consumeFormatSpec() || consumeCharacter();
|
136
|
+
}
|
137
|
+
|
138
|
+
return result.join('');
|
139
|
+
}
|
140
|
+
|
141
|
+
assertEq("Test single replacement", formatText("{0}", "hello"), "hello");
|
142
|
+
assertEq("Test no replacement 1", formatText("hello"), "hello");
|
143
|
+
assertEq("Test no replacement 2",
|
144
|
+
formatText("{this is a test}", ":)"),
|
145
|
+
"{this is a test}"
|
146
|
+
);
|
147
|
+
assertEq("Test no replacement 3",
|
148
|
+
formatText("{999}", "okay"),
|
149
|
+
"{999}"
|
150
|
+
);
|
151
|
+
assertEq("Test no replacement 4",
|
152
|
+
formatText("{123"),
|
153
|
+
"{123"
|
154
|
+
);
|
155
|
+
assertEq("Test double replacement",
|
156
|
+
formatText("The second: {1}, the first: {0}", "✅", "⛔"),
|
157
|
+
"The second: ⛔, the first: ✅"
|
158
|
+
);
|
159
|
+
assertEq("Test escaping",
|
160
|
+
formatText("Escape\\d thing"), "Escaped thing");
|
161
|
+
assertEq("Test escaping 2",
|
162
|
+
formatText(" \\\\", ""), " \\");
|
163
|
+
assertEq("Test escaping 3",
|
164
|
+
formatText("\\{0}", 3), "{0}");
|
165
|
+
|
166
|
+
export { stringLookup, formatText };
|
167
|
+
export default stringLookup;
|