@kenjura/ursa 0.51.0 → 0.52.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ # 0.52.0
2
+ 2025-12-21
3
+
4
+ - Fixed search results not displaying correctly when no matches are found
5
+
1
6
  # 0.51.0
2
7
  2025-12-21
3
8
 
@@ -6,19 +6,6 @@
6
6
  <title>${title}</title>
7
7
  <link rel="stylesheet" href="/public/default.css" />
8
8
  ${styleLink}
9
- <script>
10
- // Search index loaded asynchronously from separate file to reduce page size
11
- window.SEARCH_INDEX = ${searchIndex};
12
- // Lazy load full search index if placeholder is empty
13
- if (!window.SEARCH_INDEX || window.SEARCH_INDEX.length === 0) {
14
- fetch('/public/search-index.json')
15
- .then(r => r.json())
16
- .then(data => { window.SEARCH_INDEX = data; })
17
- .catch(() => { window.SEARCH_INDEX = []; });
18
- }
19
- </script>
20
- <script src="/public/search.js"></script>
21
-
22
9
  </head>
23
10
 
24
11
  <body data-template-id="default">
@@ -47,6 +34,7 @@
47
34
  <script src="/public/toc.js"></script>
48
35
  <script src="/public/toc-generator.js"></script>
49
36
  <script src="/public/menu.js"></script>
37
+ <script src="/public/search.js"></script>
50
38
  <script src="/public/sectionify.js"></script>
51
39
  <script src="/public/sticky.js"></script>
52
40
  </body>
package/meta/default.css CHANGED
@@ -142,6 +142,14 @@ nav#nav-global {
142
142
  opacity: 0.7;
143
143
  }
144
144
 
145
+ .search-result-message {
146
+ padding: 16px;
147
+ text-align: center;
148
+ color: var(--text-color);
149
+ opacity: 0.7;
150
+ font-style: italic;
151
+ }
152
+
145
153
  nav#nav-main {
146
154
  position: fixed;
147
155
  top: calc(var(--global-nav-height) + 0.5rem);
package/meta/search.js CHANGED
@@ -1,10 +1,16 @@
1
1
  // Global search functionality with typeahead
2
+ // Supports lazy-loading of search index for large datasets
3
+
2
4
  class GlobalSearch {
3
5
  constructor() {
4
6
  this.searchInput = document.getElementById('global-search');
5
7
  this.searchResults = null;
6
- this.searchIndex = window.SEARCH_INDEX || [];
8
+ this.searchIndex = null; // Will be loaded asynchronously
9
+ this.indexLoading = false;
10
+ this.indexLoaded = false;
7
11
  this.currentSelection = -1;
12
+ this._lastResults = null;
13
+ this.MIN_QUERY_LENGTH = 3;
8
14
 
9
15
  if (!this.searchInput) return;
10
16
 
@@ -14,6 +20,8 @@ class GlobalSearch {
14
20
  init() {
15
21
  this.createResultsContainer();
16
22
  this.bindEvents();
23
+ // Start loading the index immediately (but don't block)
24
+ this.loadSearchIndex();
17
25
  }
18
26
 
19
27
  createResultsContainer() {
@@ -23,6 +31,43 @@ class GlobalSearch {
23
31
  this.searchInput.parentNode.appendChild(this.searchResults);
24
32
  }
25
33
 
34
+ /**
35
+ * Load search index from external JSON file
36
+ * This is done asynchronously to avoid blocking page render
37
+ */
38
+ async loadSearchIndex() {
39
+ // Check if index was already embedded in page (legacy support)
40
+ if (window.SEARCH_INDEX && Array.isArray(window.SEARCH_INDEX) && window.SEARCH_INDEX.length > 0) {
41
+ this.searchIndex = window.SEARCH_INDEX;
42
+ this.indexLoaded = true;
43
+ return;
44
+ }
45
+
46
+ this.indexLoading = true;
47
+
48
+ try {
49
+ const response = await fetch('/public/search-index.json');
50
+ if (!response.ok) {
51
+ throw new Error(`HTTP ${response.status}`);
52
+ }
53
+ const data = await response.json();
54
+ this.searchIndex = data;
55
+ this.indexLoaded = true;
56
+ window.SEARCH_INDEX = data; // Cache globally for potential reuse
57
+
58
+ // If user was waiting, trigger search now
59
+ if (this.searchInput.value.length >= this.MIN_QUERY_LENGTH) {
60
+ this.handleSearch(this.searchInput.value);
61
+ }
62
+ } catch (error) {
63
+ console.error('Failed to load search index:', error);
64
+ this.searchIndex = [];
65
+ this.indexLoaded = true; // Mark as loaded (with empty) to stop loading indicator
66
+ } finally {
67
+ this.indexLoading = false;
68
+ }
69
+ }
70
+
26
71
  bindEvents() {
27
72
  // Input events
28
73
  this.searchInput.addEventListener('input', (e) => {
@@ -57,55 +102,103 @@ class GlobalSearch {
57
102
  }
58
103
 
59
104
  handleSearch(query) {
60
- if (!query || query.length < 2) {
105
+ const trimmedQuery = (query || '').trim();
106
+
107
+ // Clear results for empty query
108
+ if (!trimmedQuery) {
61
109
  this.hideResults();
62
110
  return;
63
111
  }
64
112
 
65
- const results = this.search(query);
66
- this.displayResults(results);
113
+ // Show minimum character message
114
+ if (trimmedQuery.length < this.MIN_QUERY_LENGTH) {
115
+ this.showMessage(`Type at least ${this.MIN_QUERY_LENGTH} characters to search`);
116
+ return;
117
+ }
118
+
119
+ // Show loading indicator if index isn't ready
120
+ if (this.indexLoading || !this.indexLoaded) {
121
+ this.showMessage('Loading search index...');
122
+ return;
123
+ }
124
+
125
+ const results = this.search(trimmedQuery);
126
+ this._lastResults = results;
127
+ this.displayResults(results, trimmedQuery);
128
+ }
129
+
130
+ /**
131
+ * Show a message in the results dropdown (for loading, errors, etc.)
132
+ */
133
+ showMessage(message) {
134
+ this.searchResults.innerHTML = '';
135
+ this.currentSelection = -1;
136
+
137
+ const item = document.createElement('div');
138
+ item.className = 'search-result-message';
139
+ item.textContent = message;
140
+ this.searchResults.appendChild(item);
141
+
142
+ this.showResults();
67
143
  }
68
144
 
69
145
  search(query) {
146
+ if (!this.searchIndex || !Array.isArray(this.searchIndex)) {
147
+ return [];
148
+ }
149
+
70
150
  const normalizedQuery = query.toLowerCase().trim();
151
+ const queryWords = normalizedQuery.split(/\s+/);
71
152
  const results = [];
72
153
 
73
154
  // Search through the index
74
155
  this.searchIndex.forEach(item => {
75
- const titleMatch = item.title.toLowerCase().includes(normalizedQuery);
76
- const pathMatch = item.path.toLowerCase().includes(normalizedQuery);
77
- const contentMatch = item.content && item.content.toLowerCase().includes(normalizedQuery);
156
+ const titleLower = (item.title || '').toLowerCase();
157
+ const pathLower = (item.path || '').toLowerCase();
158
+ const contentLower = (item.content || '').toLowerCase();
78
159
 
79
- if (titleMatch || pathMatch || contentMatch) {
80
- let score = 0;
81
-
82
- // Boost exact title matches
83
- if (item.title.toLowerCase() === normalizedQuery) score += 100;
84
- else if (item.title.toLowerCase().startsWith(normalizedQuery)) score += 50;
85
- else if (titleMatch) score += 25;
86
-
87
- // Boost path matches
88
- if (pathMatch) score += 10;
89
-
90
- // Content matches get lower score
91
- if (contentMatch) score += 5;
92
-
93
- results.push({ ...item, score });
94
- }
160
+ // Check if all query words match somewhere
161
+ const allWordsMatch = queryWords.every(word =>
162
+ titleLower.includes(word) ||
163
+ pathLower.includes(word) ||
164
+ contentLower.includes(word)
165
+ );
166
+
167
+ if (!allWordsMatch) return;
168
+
169
+ let score = 0;
170
+
171
+ // Boost exact title matches
172
+ if (titleLower === normalizedQuery) score += 100;
173
+ else if (titleLower.startsWith(normalizedQuery)) score += 50;
174
+ else if (titleLower.includes(normalizedQuery)) score += 25;
175
+
176
+ // Boost path matches
177
+ if (pathLower.includes(normalizedQuery)) score += 10;
178
+
179
+ // Content matches get lower score
180
+ if (contentLower.includes(normalizedQuery)) score += 5;
181
+
182
+ // Bonus for each word match in title
183
+ queryWords.forEach(word => {
184
+ if (titleLower.includes(word)) score += 3;
185
+ });
186
+
187
+ results.push({ ...item, score });
95
188
  });
96
189
 
97
190
  // Sort by score, then by title
98
191
  return results
99
192
  .sort((a, b) => {
100
193
  if (b.score !== a.score) return b.score - a.score;
101
- return a.title.localeCompare(b.title);
194
+ return (a.title || '').localeCompare(b.title || '');
102
195
  })
103
196
  .slice(0, 10); // Limit to 10 results
104
197
  }
105
198
 
106
- displayResults(results) {
199
+ displayResults(results, query) {
107
200
  if (results.length === 0) {
108
- this.hideResults();
201
+ this.showMessage(`No results for "${query}"`);
109
202
  return;
110
203
  }
111
204
 
@@ -119,11 +212,11 @@ class GlobalSearch {
119
212
 
120
213
  const title = document.createElement('div');
121
214
  title.className = 'search-result-title';
122
- title.textContent = result.title;
215
+ title.textContent = result.title || 'Untitled';
123
216
 
124
217
  const path = document.createElement('div');
125
218
  path.className = 'search-result-path';
126
- path.textContent = result.path;
219
+ path.textContent = result.path || result.url || '';
127
220
 
128
221
  item.appendChild(title);
129
222
  item.appendChild(path);
@@ -133,6 +226,12 @@ class GlobalSearch {
133
226
  this.navigateToResult(result);
134
227
  });
135
228
 
229
+ // Mouse hover selection
230
+ item.addEventListener('mouseenter', () => {
231
+ this.currentSelection = index;
232
+ this.updateSelection();
233
+ });
234
+
136
235
  this.searchResults.appendChild(item);
137
236
  });
138
237
 
@@ -154,24 +253,24 @@ class GlobalSearch {
154
253
  switch (e.key) {
155
254
  case 'ArrowDown':
156
255
  e.preventDefault();
157
- this.currentSelection = Math.min(this.currentSelection + 1, items.length - 1);
158
- this.updateSelection();
256
+ if (items.length > 0) {
257
+ this.currentSelection = Math.min(this.currentSelection + 1, items.length - 1);
258
+ this.updateSelection();
259
+ }
159
260
  break;
160
261
 
161
262
  case 'ArrowUp':
162
263
  e.preventDefault();
163
- this.currentSelection = Math.max(this.currentSelection - 1, -1);
164
- this.updateSelection();
264
+ if (items.length > 0) {
265
+ this.currentSelection = Math.max(this.currentSelection - 1, 0);
266
+ this.updateSelection();
267
+ }
165
268
  break;
166
269
 
167
270
  case 'Enter':
168
271
  e.preventDefault();
169
- if (this.currentSelection >= 0 && items[this.currentSelection]) {
170
- const index = items[this.currentSelection].dataset.index;
171
- const results = this.getLastSearchResults();
172
- if (results && results[index]) {
173
- this.navigateToResult(results[index]);
174
- }
272
+ if (this.currentSelection >= 0 && this._lastResults && this._lastResults[this.currentSelection]) {
273
+ this.navigateToResult(this._lastResults[this.currentSelection]);
175
274
  }
176
275
  break;
177
276
 
@@ -187,22 +286,23 @@ class GlobalSearch {
187
286
  items.forEach((item, index) => {
188
287
  item.classList.toggle('selected', index === this.currentSelection);
189
288
  });
190
- }
191
-
192
- getLastSearchResults() {
193
- // Store last search results for keyboard navigation
194
- if (!this._lastResults) {
195
- this._lastResults = this.search(this.searchInput.value);
289
+
290
+ // Scroll selected item into view
291
+ if (this.currentSelection >= 0 && items[this.currentSelection]) {
292
+ items[this.currentSelection].scrollIntoView({ block: 'nearest' });
196
293
  }
197
- return this._lastResults;
198
294
  }
199
295
 
200
296
  navigateToResult(result) {
201
- window.location.href = result.url;
297
+ if (result.url) {
298
+ window.location.href = result.url;
299
+ } else if (result.path) {
300
+ window.location.href = result.path;
301
+ }
202
302
  }
203
303
  }
204
304
 
205
305
  // Initialize when DOM is loaded
206
306
  document.addEventListener('DOMContentLoaded', () => {
207
- new GlobalSearch();
307
+ window.globalSearch = new GlobalSearch();
208
308
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.51.0",
5
+ "version": "0.52.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {