hematite 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +58 -0
  4. data/_config.yml +33 -0
  5. data/_data/strings/en.yml +32 -0
  6. data/_data/strings/es.yml +33 -0
  7. data/_includes/img/hamburger_menu.svg +78 -0
  8. data/_includes/img/search_icon.svg +99 -0
  9. data/_includes/katex_includes.html +26 -0
  10. data/_includes/nav/page_navigation.html +10 -0
  11. data/_includes/nav/pages_list.html +17 -0
  12. data/_includes/nav/pinned_page.html +12 -0
  13. data/_includes/nav/sidebar.html +25 -0
  14. data/_layouts/calendar.html +36 -0
  15. data/_layouts/default.html +31 -0
  16. data/_layouts/page.html +5 -0
  17. data/_layouts/post.html +42 -0
  18. data/_sass/_animations.scss +16 -0
  19. data/_sass/_calendar.scss +63 -0
  20. data/_sass/_colors.scss +73 -0
  21. data/_sass/_elements.scss +125 -0
  22. data/_sass/_layout.scss +224 -0
  23. data/_sass/_nav.scss +180 -0
  24. data/_sass/_rogue.scss +50 -0
  25. data/_sass/_sizes.scss +18 -0
  26. data/_sass/hematite.scss +10 -0
  27. data/assets/html/all_tags.html +26 -0
  28. data/assets/img/favicon.svg +12 -0
  29. data/assets/js/AnimationUtil.mjs +72 -0
  30. data/assets/js/AsyncUtil.mjs +18 -0
  31. data/assets/js/DateUtil.mjs +123 -0
  32. data/assets/js/PageAlert.mjs +143 -0
  33. data/assets/js/UrlHelper.mjs +118 -0
  34. data/assets/js/assertions.mjs +9 -0
  35. data/assets/js/dropdownExpander.mjs +78 -0
  36. data/assets/js/layout/calendar.mjs +478 -0
  37. data/assets/js/layout/post.mjs +65 -0
  38. data/assets/js/linkButtonGenerator.mjs +45 -0
  39. data/assets/js/main.mjs +19 -0
  40. data/assets/js/search.mjs +358 -0
  41. data/assets/js/sidebar.mjs +97 -0
  42. data/assets/js/string_data.mjs +19 -0
  43. data/assets/js/strings.mjs +167 -0
  44. data/assets/plugin/katex/README.md +119 -0
  45. data/assets/plugin/katex/contrib/auto-render.min.js +1 -0
  46. data/assets/plugin/katex/contrib/copy-tex.min.css +1 -0
  47. data/assets/plugin/katex/contrib/copy-tex.min.js +1 -0
  48. data/assets/plugin/katex/contrib/mathtex-script-type.min.js +1 -0
  49. data/assets/plugin/katex/contrib/mhchem.min.js +1 -0
  50. data/assets/plugin/katex/contrib/render-a11y-string.min.js +1 -0
  51. data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  52. data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  53. data/assets/plugin/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  54. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  55. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  56. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  57. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  58. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  59. data/assets/plugin/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  60. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  61. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  62. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  63. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  64. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  65. data/assets/plugin/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  66. data/assets/plugin/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  67. data/assets/plugin/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  68. data/assets/plugin/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  69. data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  70. data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  71. data/assets/plugin/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  72. data/assets/plugin/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  73. data/assets/plugin/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  74. data/assets/plugin/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  75. data/assets/plugin/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  76. data/assets/plugin/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  77. data/assets/plugin/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  78. data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  79. data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  80. data/assets/plugin/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  81. data/assets/plugin/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  82. data/assets/plugin/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  83. data/assets/plugin/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  84. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  85. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  86. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  87. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  88. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  89. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  90. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  91. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  92. data/assets/plugin/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  93. data/assets/plugin/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  94. data/assets/plugin/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  95. data/assets/plugin/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  96. data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  97. data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  98. data/assets/plugin/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  99. data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  100. data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  101. data/assets/plugin/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  102. data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  103. data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  104. data/assets/plugin/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  105. data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  106. data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  107. data/assets/plugin/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  108. data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  109. data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  110. data/assets/plugin/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  111. data/assets/plugin/katex/katex.min.css +1 -0
  112. data/assets/plugin/katex/katex.min.js +1 -0
  113. data/assets/search_data.json +36 -0
  114. data/assets/style.scss +15 -0
  115. 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;