hematite 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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;
|