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.
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;