datalog-theme 0.6.0

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 (180) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +162 -0
  3. data/CITATION.cff +29 -0
  4. data/LICENSE +21 -0
  5. data/README.md +257 -0
  6. data/_data/academic.yml +217 -0
  7. data/_data/cdn-integrity.yml +51 -0
  8. data/_data/config/author.yml +121 -0
  9. data/_data/config/features.yml +262 -0
  10. data/_data/config/site.yml +42 -0
  11. data/_data/config/theme.yml +181 -0
  12. data/_data/datasets.yml +28 -0
  13. data/_data/i18n/en.yml +438 -0
  14. data/_data/i18n/es.yml +438 -0
  15. data/_data/i18n/pt.yml +438 -0
  16. data/_data/js_manifest.json +22 -0
  17. data/_data/js_meta.json +371 -0
  18. data/_data/navigation.yml +145 -0
  19. data/_data/projects.yml +41 -0
  20. data/_data/publications.yml +28 -0
  21. data/_data/social.yml +73 -0
  22. data/_data/visualizations.yml +51 -0
  23. data/_includes/analytics/dashboard.html +149 -0
  24. data/_includes/analytics.html +9 -0
  25. data/_includes/components/academic-dashboard.html +347 -0
  26. data/_includes/components/advanced-search.html +682 -0
  27. data/_includes/components/api-function.html +94 -0
  28. data/_includes/components/author-bio.html +166 -0
  29. data/_includes/components/bookmark-system.html +96 -0
  30. data/_includes/components/breadcrumbs.html +89 -0
  31. data/_includes/components/citation-tools.html +94 -0
  32. data/_includes/components/comments.html +244 -0
  33. data/_includes/components/content-provenance.html +43 -0
  34. data/_includes/components/content-recommendations.html +228 -0
  35. data/_includes/components/difficulty-badge.html +84 -0
  36. data/_includes/components/email-preferences.html +200 -0
  37. data/_includes/components/enhanced-code-block.html +212 -0
  38. data/_includes/components/enhanced-metadata.html +228 -0
  39. data/_includes/components/enhanced-toc.html +209 -0
  40. data/_includes/components/hero.html +15 -0
  41. data/_includes/components/language-switcher.html +396 -0
  42. data/_includes/components/math-fallback.html +19 -0
  43. data/_includes/components/navigation-enhancements.html +454 -0
  44. data/_includes/components/newsletter-signup.html +178 -0
  45. data/_includes/components/open-science-badges.html +16 -0
  46. data/_includes/components/package-install.html +194 -0
  47. data/_includes/components/performance-monitor.html +170 -0
  48. data/_includes/components/popular-posts.html +233 -0
  49. data/_includes/components/post-hero.html +62 -0
  50. data/_includes/components/reading-progress.html +133 -0
  51. data/_includes/components/reading-time.html +121 -0
  52. data/_includes/components/responsive-image.html +63 -0
  53. data/_includes/components/search-facets.html +307 -0
  54. data/_includes/components/series-navigation.html +124 -0
  55. data/_includes/components/social-proof.html +34 -0
  56. data/_includes/components/social-share.html +119 -0
  57. data/_includes/components/user-preferences.html +566 -0
  58. data/_includes/components/visualization-card.html +24 -0
  59. data/_includes/components/viz-table-fallback.html +19 -0
  60. data/_includes/critical-css/default.html +0 -0
  61. data/_includes/critical-css/home.html +0 -0
  62. data/_includes/critical-css/post.html +0 -0
  63. data/_includes/csp-meta.html +17 -0
  64. data/_includes/footer/nav-column.html +31 -0
  65. data/_includes/footer.html +76 -0
  66. data/_includes/head.html +337 -0
  67. data/_includes/header/navigation.html +48 -0
  68. data/_includes/header.html +70 -0
  69. data/_includes/helpers/array-to-sentence.html +21 -0
  70. data/_includes/helpers/date-format.html +16 -0
  71. data/_includes/helpers/link-with-icon.html +26 -0
  72. data/_includes/layouts/default/article.html +67 -0
  73. data/_includes/meta/language-attributes.html +25 -0
  74. data/_includes/meta/math-config.html +21 -0
  75. data/_includes/meta/schema.html +153 -0
  76. data/_includes/meta/scripts-loader.html +52 -0
  77. data/_includes/post/related-posts.html +63 -0
  78. data/_includes/scripts.html +72 -0
  79. data/_includes/skip-link.html +1 -0
  80. data/_includes/toc.html +2 -0
  81. data/_layouts/archive.html +282 -0
  82. data/_layouts/dataset.html +52 -0
  83. data/_layouts/default.html +33 -0
  84. data/_layouts/home.html +48 -0
  85. data/_layouts/notebook.html +173 -0
  86. data/_layouts/package.html +238 -0
  87. data/_layouts/page.html +16 -0
  88. data/_layouts/portfolio.html +41 -0
  89. data/_layouts/post-sidebar.html +183 -0
  90. data/_layouts/post.html +338 -0
  91. data/_layouts/project.html +252 -0
  92. data/_layouts/research.html +504 -0
  93. data/_plugins/analytics_dashboard.rb +315 -0
  94. data/_plugins/config_validator.rb +397 -0
  95. data/_plugins/csp_generator.rb +115 -0
  96. data/_plugins/datalog_bibliography.rb +19 -0
  97. data/_plugins/datalog_comments.rb +18 -0
  98. data/_plugins/datalog_slides.rb +20 -0
  99. data/_plugins/front_matter_compat.rb +109 -0
  100. data/_plugins/i18n.rb +160 -0
  101. data/_plugins/image_optimizer.rb +445 -0
  102. data/_plugins/math_preprocessor.rb +185 -0
  103. data/_plugins/notebook_converter.rb +1107 -0
  104. data/_plugins/plugin_loader.rb +185 -0
  105. data/_plugins/publications_generator.rb +234 -0
  106. data/_plugins/reading_time.rb +15 -0
  107. data/_plugins/search_normalizer.rb +74 -0
  108. data/_plugins/sri_filter.rb +18 -0
  109. data/_plugins/toc_filter.rb +68 -0
  110. data/_plugins/warning_filter.rb +3 -0
  111. data/_sass/_base.scss +142 -0
  112. data/_sass/_components.scss +2380 -0
  113. data/_sass/_header.scss +470 -0
  114. data/_sass/_interactive.scss +344 -0
  115. data/_sass/_layout.scss +1760 -0
  116. data/_sass/_mathematical.scss +368 -0
  117. data/_sass/_mixins.scss +99 -0
  118. data/_sass/_package-docs.scss +715 -0
  119. data/_sass/_phase1-enhancements.scss +721 -0
  120. data/_sass/_phase3-enhancements.scss +874 -0
  121. data/_sass/_phase4-enhancements.scss +1214 -0
  122. data/_sass/_phase5-enhancements.scss +414 -0
  123. data/_sass/_search.scss +654 -0
  124. data/_sass/_syntax-highlighting.scss +128 -0
  125. data/_sass/_theme.scss +79 -0
  126. data/_sass/_typography.scss +148 -0
  127. data/_sass/_utilities.scss +334 -0
  128. data/_sass/_variables.scss +153 -0
  129. data/_sass/_visualizations.scss +242 -0
  130. data/assets/css/main.scss +4 -0
  131. data/assets/img/20220607123041_detail.001.png +0 -0
  132. data/assets/img/favicons/android-chrome-192x192.png +0 -0
  133. data/assets/img/favicons/android-chrome-512x512.png +0 -0
  134. data/assets/img/favicons/apple-touch-icon.png +0 -0
  135. data/assets/img/favicons/favicon-16x16.png +0 -0
  136. data/assets/img/favicons/favicon-32x32.png +0 -0
  137. data/assets/img/favicons/favicon.ico +0 -0
  138. data/assets/img/favicons/site.webmanifest +21 -0
  139. data/assets/img/portfolio-placeholder.svg +20 -0
  140. data/assets/js/academic.js +262 -0
  141. data/assets/js/analytics-dashboard.js +382 -0
  142. data/assets/js/core/dark-mode.js +79 -0
  143. data/assets/js/core/github-cards.js +123 -0
  144. data/assets/js/core/language-filter.js +69 -0
  145. data/assets/js/core/navigation.js +184 -0
  146. data/assets/js/core/scroll-progress.js +45 -0
  147. data/assets/js/core/search-hotkeys.js +62 -0
  148. data/assets/js/core/skip-links.js +62 -0
  149. data/assets/js/loader.js +163 -0
  150. data/assets/js/main.js +23 -0
  151. data/assets/js/math.js +818 -0
  152. data/assets/js/notebook.js +158 -0
  153. data/assets/js/search/analytics.js +91 -0
  154. data/assets/js/search/app.js +271 -0
  155. data/assets/js/search/autocomplete.js +120 -0
  156. data/assets/js/search/engine.js +260 -0
  157. data/assets/js/search/filters.js +38 -0
  158. data/assets/js/search/render.js +217 -0
  159. data/assets/js/search/utils.js +99 -0
  160. data/assets/js/search.js +354 -0
  161. data/assets/js/visualizations.js +816 -0
  162. data/assets/publications/datalog-publications.bib +8 -0
  163. data/assets/publications/datalog-publications.ris +9 -0
  164. data/assets/publications/publications.bib +30 -0
  165. data/assets/templates/diogo-ribeiro-cv.md +31 -0
  166. data/assets/templates/diogo-ribeiro-cv.tex +32 -0
  167. data/bin/datalog +26 -0
  168. data/datalog-theme.gemspec +72 -0
  169. data/lib/datalog/cli.rb +563 -0
  170. data/lib/datalog/plugin_system/dependency_resolver.rb +223 -0
  171. data/lib/datalog/plugin_system.rb +237 -0
  172. data/lib/datalog/plugins/citations.rb +182 -0
  173. data/lib/datalog/plugins/comments.rb +183 -0
  174. data/lib/datalog/plugins/search.rb +45 -0
  175. data/lib/datalog/plugins/slides.rb +121 -0
  176. data/lib/datalog/theme/theme.rb +18 -0
  177. data/lib/datalog/theme/version.rb +7 -0
  178. data/lib/datalog/warning_filter.rb +28 -0
  179. data/lib/datalog-theme.rb +17 -0
  180. metadata +555 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @fileoverview Jupyter notebook rendering support.
3
+ * Handles Plotly, Vega visualizations, cell annotations, and scroll spy.
4
+ * @module notebook
5
+ */
6
+
7
+ /**
8
+ * Dynamically loads a script if not already present.
9
+ * @param {string} src - The script URL to load
10
+ * @returns {Promise<void>} Resolves when script is loaded
11
+ */
12
+ export const loadScript = (src) =>
13
+ new Promise((resolve, reject) => {
14
+ if (document.querySelector(`script[src="${src}"]`)) {
15
+ resolve();
16
+ return;
17
+ }
18
+ const script = document.createElement('script');
19
+ script.src = src;
20
+ script.async = true;
21
+ script.onload = () => resolve();
22
+ script.onerror = (event) => reject(event);
23
+ document.head.appendChild(script);
24
+ });
25
+
26
+ /**
27
+ * Initializes Plotly visualizations from notebook output elements.
28
+ * @returns {Promise<void>} Resolves when all Plotly figures are rendered
29
+ */
30
+ export const initPlotly = () => {
31
+ const plotlyTargets = document.querySelectorAll('.notebook-output-plotly');
32
+ if (!plotlyTargets.length) {
33
+ return Promise.resolve();
34
+ }
35
+
36
+ const ensurePlotly = () =>
37
+ typeof window.Plotly !== 'undefined'
38
+ ? Promise.resolve(window.Plotly)
39
+ : loadScript('https://cdn.plot.ly/plotly-2.27.0.min.js').then(() => window.Plotly);
40
+
41
+ return ensurePlotly().then((Plotly) => {
42
+ plotlyTargets.forEach((target) => {
43
+ try {
44
+ const spec = JSON.parse(target.dataset.plotly || '{}');
45
+ const layout = spec.layout || {};
46
+ const data = Array.isArray(spec.data) ? spec.data : Array.isArray(spec) ? spec : [];
47
+ Plotly.newPlot(target, data, layout, { responsive: true });
48
+ } catch (error) {
49
+ target.replaceChildren();
50
+ target.insertAdjacentHTML('afterbegin',
51
+ '<p class="notebook-output__warning">Plotly figure could not be rendered. Open in Binder or Colab to interact.</p>');
52
+ }
53
+ });
54
+ });
55
+ };
56
+
57
+ /**
58
+ * Initializes Vega/Vega-Lite visualizations from notebook output elements.
59
+ * @returns {Promise<void>} Resolves when all Vega specs are rendered
60
+ */
61
+ export const initVega = () => {
62
+ const vegaTargets = document.querySelectorAll('.notebook-output-vega');
63
+ if (!vegaTargets.length) {
64
+ return Promise.resolve();
65
+ }
66
+
67
+ const ensureVega = () =>
68
+ loadScript('https://cdn.jsdelivr.net/npm/vega@5')
69
+ .then(() => loadScript('https://cdn.jsdelivr.net/npm/vega-lite@5'))
70
+ .then(() => loadScript('https://cdn.jsdelivr.net/npm/vega-embed@6'));
71
+
72
+ return ensureVega().then(() => {
73
+ vegaTargets.forEach((target) => {
74
+ try {
75
+ const spec = JSON.parse(target.dataset.vega || '{}');
76
+ window.vegaEmbed(target, spec, { actions: false });
77
+ } catch (error) {
78
+ target.replaceChildren();
79
+ target.insertAdjacentHTML('afterbegin',
80
+ '<p class="notebook-output__warning">Vega specification could not be parsed. Open in Binder for the interactive version.</p>');
81
+ }
82
+ });
83
+ });
84
+ };
85
+
86
+ /**
87
+ * Annotates notebook cells with anchor IDs for navigation.
88
+ * @returns {void}
89
+ */
90
+ export const annotateCells = () => {
91
+ const segments = document.querySelectorAll('.notebook-segment');
92
+ segments.forEach((segment, index) => {
93
+ const anchorId = segment.id || `cell-${index + 1}`;
94
+ segment.id = anchorId;
95
+ segment.setAttribute('data-cell-anchor', anchorId);
96
+ });
97
+ };
98
+
99
+ /**
100
+ * Sets up scroll spy to highlight active cell in sidebar navigation.
101
+ * @returns {void}
102
+ */
103
+ export const handleScrollSpy = () => {
104
+ const observerOptions = {
105
+ root: null,
106
+ rootMargin: '0px 0px -65%',
107
+ threshold: 0
108
+ };
109
+
110
+ const entries = document.querySelectorAll('.notebook-segment');
111
+ const sidebarLinks = document.querySelectorAll('[data-cell-anchor-link]');
112
+
113
+ if (!entries.length || !sidebarLinks.length) {
114
+ return;
115
+ }
116
+
117
+ const observer = new IntersectionObserver((changes) => {
118
+ changes.forEach((change) => {
119
+ if (change.isIntersecting) {
120
+ sidebarLinks.forEach((link) => {
121
+ link.classList.toggle('is-active', link.hash === `#${change.target.id}`);
122
+ });
123
+ }
124
+ });
125
+ }, observerOptions);
126
+
127
+ entries.forEach((entry) => observer.observe(entry));
128
+ };
129
+
130
+ /**
131
+ * Initializes all notebook features including visualizations and navigation.
132
+ * @returns {void}
133
+ */
134
+ export const initNotebookFeatures = () => {
135
+ annotateCells();
136
+ initPlotly().catch(() => {
137
+ document.querySelectorAll('.notebook-output-plotly').forEach((target) => {
138
+ target.replaceChildren();
139
+ target.insertAdjacentHTML('afterbegin',
140
+ '<p class="notebook-output__warning">Plotly assets failed to load. Launch Binder/Colab to view the interactive chart.</p>');
141
+ });
142
+ });
143
+ initVega().catch(() => {
144
+ document.querySelectorAll('.notebook-output-vega').forEach((target) => {
145
+ target.replaceChildren();
146
+ target.insertAdjacentHTML('afterbegin',
147
+ '<p class="notebook-output__warning">Vega assets failed to load. Launch Binder/Colab to view the interactive chart.</p>');
148
+ });
149
+ });
150
+ handleScrollSpy();
151
+ };
152
+
153
+ // Auto-initialize when module loads (backward compatibility)
154
+ if (typeof window !== 'undefined' && document.readyState === 'loading') {
155
+ document.addEventListener('DOMContentLoaded', initNotebookFeatures);
156
+ } else if (typeof window !== 'undefined') {
157
+ initNotebookFeatures();
158
+ }
@@ -0,0 +1,91 @@
1
+ const ANALYTICS_STORAGE_KEY = "datalog-search-analytics";
2
+
3
+ export function createAnalyticsManager({ panel, list, empty, onSelect }) {
4
+ if (!panel || !list || !empty) {
5
+ return {
6
+ record() {},
7
+ render() {}
8
+ };
9
+ }
10
+
11
+ const analytics = loadAnalytics();
12
+ let lastQuery = "";
13
+
14
+ function loadAnalytics() {
15
+ // Search queries become keys on this object, so it is built with a null
16
+ // prototype and filled by explicit own-key copy. A visitor searching for
17
+ // "__proto__" or "constructor" then records a plain entry instead of
18
+ // reaching Object.prototype.
19
+ const counts = Object.create(null);
20
+
21
+ try {
22
+ const stored = window.localStorage.getItem(ANALYTICS_STORAGE_KEY);
23
+ if (stored) {
24
+ const parsed = JSON.parse(stored);
25
+ if (parsed && typeof parsed === "object") {
26
+ Object.entries(parsed).forEach(([query, count]) => {
27
+ if (typeof count === "number" && Number.isFinite(count)) {
28
+ counts[query] = count;
29
+ }
30
+ });
31
+ }
32
+ }
33
+ } catch (error) {
34
+ console.warn("Unable to load stored analytics", error);
35
+ }
36
+
37
+ return counts;
38
+ }
39
+
40
+ function saveAnalytics() {
41
+ try {
42
+ window.localStorage.setItem(ANALYTICS_STORAGE_KEY, JSON.stringify(analytics));
43
+ } catch (error) {
44
+ console.warn("Unable to persist analytics", error);
45
+ }
46
+ }
47
+
48
+ function record(query) {
49
+ const value = (query || "").trim();
50
+ if (!value || value === lastQuery) {
51
+ return;
52
+ }
53
+ lastQuery = value;
54
+ analytics[value] = (analytics[value] || 0) + 1;
55
+ saveAnalytics();
56
+ render();
57
+ }
58
+
59
+ function render() {
60
+ const entries = Object.entries(analytics)
61
+ .filter(([query]) => query && query.trim().length > 1)
62
+ .sort((a, b) => b[1] - a[1])
63
+ .slice(0, 5);
64
+
65
+ list.replaceChildren();
66
+ if (entries.length === 0) {
67
+ empty.hidden = false;
68
+ return;
69
+ }
70
+
71
+ empty.hidden = true;
72
+ const fragment = document.createDocumentFragment();
73
+ entries.forEach(([query, count]) => {
74
+ const item = document.createElement("li");
75
+ const button = document.createElement("button");
76
+ button.type = "button";
77
+ button.className = "search-analytics__query";
78
+ button.textContent = `${query} (${count})`;
79
+ button.addEventListener("click", () => {
80
+ if (typeof onSelect === "function") {
81
+ onSelect(query);
82
+ }
83
+ });
84
+ item.appendChild(button);
85
+ fragment.appendChild(item);
86
+ });
87
+ list.appendChild(fragment);
88
+ }
89
+
90
+ return { record, render };
91
+ }
@@ -0,0 +1,271 @@
1
+ import { createAnalyticsManager } from "./analytics.js";
2
+ import { createAutocomplete } from "./autocomplete.js";
3
+ import { createSearchEngine } from "./engine.js";
4
+ import { renderTagFilters } from "./filters.js";
5
+ import { renderResults } from "./render.js";
6
+
7
+ const AUTOCOMPLETE_LIMIT = 8;
8
+
9
+ export function initializeSearchApp() {
10
+ const app = document.querySelector("[data-search-app]");
11
+ if (!app) {
12
+ return;
13
+ }
14
+
15
+ const form = app.querySelector("[data-search-form]");
16
+ const input = app.querySelector("[data-search-input]");
17
+ const resultsList = app.querySelector("[data-search-results]");
18
+ const resultsMeta = app.querySelector("[data-search-meta]");
19
+ const emptyState = app.querySelector("[data-search-empty]");
20
+ const autocompletePanel = app.querySelector("[data-search-autocomplete]");
21
+ const analyticsPanel = app.querySelector("[data-search-analytics]");
22
+ const analyticsList = analyticsPanel ? analyticsPanel.querySelector(".search-app__analytics-list") : null;
23
+ const analyticsEmpty = analyticsPanel ? analyticsPanel.querySelector(".search-app__analytics-empty") : null;
24
+ const resultTemplate = document.getElementById("search-result-template");
25
+ const filterType = app.querySelector("[data-filter-type]");
26
+ const filterLanguage = app.querySelector("[data-filter-language]");
27
+ const filterDifficulty = app.querySelector("[data-filter-difficulty]");
28
+ const filterTagsContainer = app.querySelector("[data-filter-tags]");
29
+ const SEARCH_INDEX_URL = app.getAttribute("data-search-index") || "/search.json";
30
+
31
+ const selectedTags = new Set();
32
+ const searchEngine = createSearchEngine();
33
+ const analyticsManager = createAnalyticsManager({
34
+ panel: analyticsPanel,
35
+ list: analyticsList,
36
+ empty: analyticsEmpty,
37
+ onSelect: (query) => {
38
+ if (input) {
39
+ input.value = query;
40
+ executeSearch();
41
+ }
42
+ }
43
+ });
44
+ const autocomplete = createAutocomplete({
45
+ panel: autocompletePanel,
46
+ limit: AUTOCOMPLETE_LIMIT,
47
+ onSelect: (value) => {
48
+ if (value && input) {
49
+ input.value = value;
50
+ input.focus();
51
+ executeSearch();
52
+ }
53
+ }
54
+ });
55
+
56
+ let indexReady = false;
57
+
58
+ const params = new URLSearchParams(window.location.search);
59
+ if (input && params.has("q")) {
60
+ input.value = params.get("q") || "";
61
+ }
62
+ if (filterType && params.has("type")) {
63
+ filterType.value = params.get("type");
64
+ }
65
+ if (filterLanguage && params.has("language")) {
66
+ filterLanguage.value = params.get("language");
67
+ }
68
+ if (filterDifficulty && params.has("difficulty")) {
69
+ filterDifficulty.value = params.get("difficulty");
70
+ }
71
+ if (params.has("tags")) {
72
+ params
73
+ .get("tags")
74
+ .split(",")
75
+ .map((tag) => tag.trim().toLowerCase())
76
+ .filter(Boolean)
77
+ .forEach((tag) => selectedTags.add(tag));
78
+ }
79
+
80
+ focusShortcut(input, () => {
81
+ autocomplete.close();
82
+ executeSearch();
83
+ });
84
+
85
+ fetchIndex(SEARCH_INDEX_URL)
86
+ .then((data) => {
87
+ const documents = data.documents || [];
88
+ searchEngine.setDocuments(documents);
89
+ autocomplete.setSuggestions(searchEngine.suggestions());
90
+ renderTagFilters(filterTagsContainer, documents, selectedTags);
91
+ indexReady = true;
92
+ restoreInitialSearch();
93
+ })
94
+ .catch((error) => {
95
+ console.error("Failed to load search index", error);
96
+ if (resultsMeta) {
97
+ resultsMeta.textContent = "Unable to load the search index. Please try again later.";
98
+ }
99
+ });
100
+
101
+ if (form) {
102
+ form.addEventListener("submit", (event) => {
103
+ event.preventDefault();
104
+ executeSearch();
105
+ });
106
+ }
107
+
108
+ if (input) {
109
+ input.addEventListener("input", () => {
110
+ autocomplete.update(input.value);
111
+ });
112
+ input.addEventListener("keydown", (event) => {
113
+ autocomplete.handleNavigation(event, input);
114
+ });
115
+ input.addEventListener("focus", () => {
116
+ autocomplete.update(input.value);
117
+ });
118
+ }
119
+
120
+ if (filterType) {
121
+ filterType.addEventListener("change", executeSearch);
122
+ }
123
+ if (filterLanguage) {
124
+ filterLanguage.addEventListener("change", executeSearch);
125
+ }
126
+ if (filterDifficulty) {
127
+ filterDifficulty.addEventListener("change", executeSearch);
128
+ }
129
+
130
+ app.addEventListener("click", (event) => {
131
+ const trigger = event.target;
132
+ if (trigger.matches("[data-filter-tag]")) {
133
+ const tag = trigger.getAttribute("data-filter-tag");
134
+ if (tag) {
135
+ if (selectedTags.has(tag)) {
136
+ selectedTags.delete(tag);
137
+ trigger.setAttribute("aria-pressed", "false");
138
+ } else {
139
+ selectedTags.add(tag);
140
+ trigger.setAttribute("aria-pressed", "true");
141
+ }
142
+ executeSearch();
143
+ }
144
+ }
145
+ });
146
+
147
+ document.addEventListener("click", (event) => {
148
+ if (!autocompletePanel || autocompletePanel.hidden) {
149
+ return;
150
+ }
151
+ if (!app.contains(event.target)) {
152
+ autocomplete.close();
153
+ }
154
+ });
155
+
156
+ document.addEventListener("keydown", (event) => {
157
+ const activeTag = document.activeElement ? document.activeElement.tagName : "";
158
+ if (["INPUT", "TEXTAREA"].includes(activeTag)) {
159
+ return;
160
+ }
161
+ if (event.key === "/" && !event.ctrlKey && !event.metaKey && !event.altKey) {
162
+ event.preventDefault();
163
+ if (input) {
164
+ input.focus();
165
+ input.select();
166
+ }
167
+ } else if ((event.key === "k" || event.key === "K") && (event.ctrlKey || event.metaKey)) {
168
+ event.preventDefault();
169
+ if (input) {
170
+ input.focus();
171
+ input.select();
172
+ }
173
+ }
174
+ });
175
+
176
+ analyticsManager.render();
177
+
178
+ function restoreInitialSearch() {
179
+ if (input && input.value.trim()) {
180
+ executeSearch();
181
+ } else {
182
+ updateFiltersInUrl();
183
+ }
184
+ }
185
+
186
+ function executeSearch() {
187
+ if (!input) {
188
+ return;
189
+ }
190
+ if (!indexReady) {
191
+ if (resultsMeta) {
192
+ resultsMeta.textContent = "Loading technical index…";
193
+ }
194
+ return;
195
+ }
196
+
197
+ const rawQuery = input.value.trim();
198
+ const filters = {
199
+ type: filterType ? filterType.value : "",
200
+ language: filterLanguage ? filterLanguage.value : "",
201
+ difficulty: filterDifficulty ? filterDifficulty.value : "",
202
+ tags: selectedTags
203
+ };
204
+
205
+ const matches = searchEngine.search(rawQuery, filters);
206
+ renderResults(matches, rawQuery, {
207
+ resultsList,
208
+ resultsMeta,
209
+ emptyState,
210
+ template: resultTemplate
211
+ });
212
+ updateFiltersInUrl(rawQuery);
213
+ analyticsManager.record(rawQuery);
214
+ }
215
+
216
+ function updateFiltersInUrl(query) {
217
+ const next = new URL(window.location.href);
218
+ const urlParams = next.searchParams;
219
+ if (query) {
220
+ urlParams.set("q", query);
221
+ } else {
222
+ urlParams.delete("q");
223
+ }
224
+ if (filterType && filterType.value) {
225
+ urlParams.set("type", filterType.value);
226
+ } else {
227
+ urlParams.delete("type");
228
+ }
229
+ if (filterLanguage && filterLanguage.value) {
230
+ urlParams.set("language", filterLanguage.value);
231
+ } else {
232
+ urlParams.delete("language");
233
+ }
234
+ if (filterDifficulty && filterDifficulty.value) {
235
+ urlParams.set("difficulty", filterDifficulty.value);
236
+ } else {
237
+ urlParams.delete("difficulty");
238
+ }
239
+ if (selectedTags.size > 0) {
240
+ urlParams.set("tags", Array.from(selectedTags).join(","));
241
+ } else {
242
+ urlParams.delete("tags");
243
+ }
244
+ const queryString = urlParams.toString();
245
+ const nextUrl = queryString ? `${next.pathname}?${queryString}` : next.pathname;
246
+ window.history.replaceState({}, "", nextUrl);
247
+ }
248
+ }
249
+
250
+ function fetchIndex(url) {
251
+ return fetch(url, { credentials: "same-origin" }).then((response) => {
252
+ if (!response.ok) {
253
+ throw new Error(`HTTP ${response.status}`);
254
+ }
255
+ return response.json();
256
+ });
257
+ }
258
+
259
+ function focusShortcut(target, onClear) {
260
+ if (!target) {
261
+ return;
262
+ }
263
+ target.addEventListener("keydown", (event) => {
264
+ if (event.key === "Escape") {
265
+ target.value = "";
266
+ if (typeof onClear === "function") {
267
+ onClear();
268
+ }
269
+ }
270
+ });
271
+ }
@@ -0,0 +1,120 @@
1
+ import { highlightText } from "./utils.js";
2
+
3
+ export function createAutocomplete({ panel, limit = 8, onSelect }) {
4
+ if (!panel) {
5
+ return {
6
+ setSuggestions() {},
7
+ update() {},
8
+ close() {},
9
+ handleNavigation() {}
10
+ };
11
+ }
12
+
13
+ let suggestions = [];
14
+ let suggestionIndex = -1;
15
+
16
+ function setSuggestions(values) {
17
+ suggestions = Array.isArray(values) ? [...values] : [];
18
+ }
19
+
20
+ function update(query) {
21
+ suggestionIndex = -1;
22
+ const value = (query || "").trim().toLowerCase();
23
+
24
+ if (!value) {
25
+ clear();
26
+ return;
27
+ }
28
+
29
+ const matches = suggestions
30
+ .filter((item) => item && item.toLowerCase().includes(value))
31
+ .slice(0, limit);
32
+
33
+ if (matches.length === 0) {
34
+ clear();
35
+ return;
36
+ }
37
+
38
+ const fragment = document.createDocumentFragment();
39
+ matches.forEach((item, index) => {
40
+ const option = document.createElement("button");
41
+ option.type = "button";
42
+ option.className = "search-autocomplete__option";
43
+ option.setAttribute("role", "option");
44
+ option.dataset.autocompleteOption = "";
45
+ option.dataset.value = item;
46
+ option.setAttribute("aria-selected", index === suggestionIndex ? "true" : "false");
47
+ option.replaceChildren();
48
+ option.insertAdjacentHTML("beforeend", highlightText(item, value));
49
+ fragment.appendChild(option);
50
+ });
51
+
52
+ panel.replaceChildren();
53
+ panel.appendChild(fragment);
54
+ panel.hidden = false;
55
+ }
56
+
57
+ function handleNavigation(event, input) {
58
+ if (panel.hidden) {
59
+ return;
60
+ }
61
+
62
+ const options = Array.from(panel.querySelectorAll("[data-autocomplete-option]"));
63
+ if (options.length === 0) {
64
+ return;
65
+ }
66
+
67
+ if (event.key === "ArrowDown") {
68
+ event.preventDefault();
69
+ suggestionIndex = (suggestionIndex + 1) % options.length;
70
+ updateActive(options);
71
+ } else if (event.key === "ArrowUp") {
72
+ event.preventDefault();
73
+ suggestionIndex = (suggestionIndex - 1 + options.length) % options.length;
74
+ updateActive(options);
75
+ } else if (event.key === "Enter" && suggestionIndex >= 0) {
76
+ event.preventDefault();
77
+ const option = options[suggestionIndex];
78
+ const value = option ? option.dataset.value : "";
79
+ if (value && input) {
80
+ input.value = value;
81
+ }
82
+ if (typeof onSelect === "function") {
83
+ onSelect(value);
84
+ }
85
+ close();
86
+ }
87
+ }
88
+
89
+ function updateActive(options) {
90
+ options.forEach((option, index) => {
91
+ const isActive = index === suggestionIndex;
92
+ option.setAttribute("aria-selected", isActive ? "true" : "false");
93
+ option.classList.toggle("is-active", isActive);
94
+ });
95
+ }
96
+
97
+ function clear() {
98
+ panel.replaceChildren();
99
+ panel.hidden = true;
100
+ suggestionIndex = -1;
101
+ }
102
+
103
+ function close() {
104
+ clear();
105
+ }
106
+
107
+ panel.addEventListener("click", (event) => {
108
+ const target = event.target.closest("[data-autocomplete-option]");
109
+ if (!target) {
110
+ return;
111
+ }
112
+ const value = target.dataset.value || "";
113
+ if (value && typeof onSelect === "function") {
114
+ onSelect(value);
115
+ }
116
+ close();
117
+ });
118
+
119
+ return { setSuggestions, update, close, handleNavigation };
120
+ }