@kenjura/ursa 0.50.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,13 @@
1
+ # 0.52.0
2
+ 2025-12-21
3
+
4
+ - Fixed search results not displaying correctly when no matches are found
5
+
6
+ # 0.51.0
7
+ 2025-12-21
8
+
9
+ - Existing .html files are no longer overwritten by generated documents.
10
+
1
11
  # 0.50.0
2
12
  2025-12-21
3
13
 
@@ -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.50.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": {
@@ -309,6 +309,15 @@ export async function generate({
309
309
  (filename) => isDirectory(filename)
310
310
  )).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
311
311
 
312
+ // Build set of existing HTML files in source directory (these should not be overwritten)
313
+ const htmlExtensions = /\.html$/;
314
+ const existingHtmlFiles = new Set(
315
+ allSourceFilenames
316
+ .filter(f => f.match(htmlExtensions) && !f.match(hiddenOrSystemDirs))
317
+ .map(f => f.replace(source, '')) // Store relative paths for easy lookup
318
+ );
319
+ progress.log(`Found ${existingHtmlFiles.size} existing HTML files in source`);
320
+
312
321
  // Build set of valid internal paths for link validation (must be before menu)
313
322
  // Pass directories to ensure folder links are valid (auto-index generates index.html for all folders)
314
323
  const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
@@ -391,6 +400,14 @@ export async function generate({
391
400
  content: '' // Content excerpts built lazily to save memory
392
401
  });
393
402
 
403
+ // Check if a corresponding .html file already exists in source directory
404
+ const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
405
+ if (existingHtmlFiles.has(outputHtmlRelative)) {
406
+ progress.log(`⚠️ Warning: Skipping ${shortFile} - would overwrite existing ${outputHtmlRelative} in source`);
407
+ skippedCount++;
408
+ return;
409
+ }
410
+
394
411
  // Check if file needs regeneration
395
412
  const needsRegen = _clean || needsRegeneration(file, rawBody, hashCache);
396
413
 
@@ -600,16 +617,23 @@ export async function generate({
600
617
  // Clear directory index cache to free memory before processing static files
601
618
  dirIndexCache.clear();
602
619
 
603
- // copy all static files (i.e. images) with batched concurrency
620
+ // copy all static files (images and existing HTML files) with batched concurrency
604
621
  const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
605
622
  const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
606
623
  (filename) => filename.match(imageExtensions)
607
624
  );
608
- const totalStatic = allSourceFilenamesThatAreImages.length;
625
+
626
+ // Also copy existing HTML files from source to output (they're treated as static)
627
+ const allSourceFilenamesThatAreHtml = allSourceFilenames.filter(
628
+ (filename) => filename.match(/\.html$/) && !filename.match(hiddenOrSystemDirs)
629
+ );
630
+
631
+ const allStaticFiles = [...allSourceFilenamesThatAreImages, ...allSourceFilenamesThatAreHtml];
632
+ const totalStatic = allStaticFiles.length;
609
633
  let processedStatic = 0;
610
634
  let copiedStatic = 0;
611
- progress.log(`Processing ${totalStatic} static files...`);
612
- await processBatched(allSourceFilenamesThatAreImages, async (file) => {
635
+ progress.log(`Processing ${totalStatic} static files (${allSourceFilenamesThatAreImages.length} images, ${allSourceFilenamesThatAreHtml.length} HTML)...`);
636
+ await processBatched(allStaticFiles, async (file) => {
613
637
  try {
614
638
  processedStatic++;
615
639
  const shortFile = file.replace(source, '');
@@ -639,7 +663,7 @@ export async function generate({
639
663
 
640
664
  // Automatic index generation for folders without index.html
641
665
  progress.log(`Checking for missing index files...`);
642
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles);
666
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles);
643
667
 
644
668
  // Save the hash cache to .ursa folder in source directory
645
669
  if (hashCache.size > 0) {
@@ -706,8 +730,9 @@ export async function generate({
706
730
  * @param {string} footer - Footer HTML
707
731
  * @param {string[]} generatedArticles - List of source article paths that were generated
708
732
  * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
733
+ * @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
709
734
  */
710
- async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles) {
735
+ async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles) {
711
736
  // Alternate index file names to look for (in priority order)
712
737
  const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
713
738
 
@@ -736,6 +761,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
736
761
 
737
762
  let generatedCount = 0;
738
763
  let renamedCount = 0;
764
+ let skippedHtmlCount = 0;
739
765
 
740
766
  for (const dir of outputDirs) {
741
767
  const indexPath = join(dir, 'index.html');
@@ -745,7 +771,15 @@ async function generateAutoIndices(output, directories, source, templates, menu,
745
771
  continue;
746
772
  }
747
773
 
748
- // Skip if index.html already exists (e.g., created by previous run)
774
+ // Check if there's an existing index.html in the source directory (don't overwrite it)
775
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
776
+ const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
777
+ if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
778
+ skippedHtmlCount++;
779
+ continue; // Don't overwrite existing source HTML
780
+ }
781
+
782
+ // Skip if index.html already exists in output (e.g., created by previous run)
749
783
  if (existsSync(indexPath)) {
750
784
  continue;
751
785
  }
@@ -860,8 +894,12 @@ async function generateAutoIndices(output, directories, source, templates, menu,
860
894
  }
861
895
  }
862
896
 
863
- if (generatedCount > 0 || renamedCount > 0) {
864
- progress.done('Auto-index', `${generatedCount} generated, ${renamedCount} promoted`);
897
+ if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
898
+ let summary = `${generatedCount} generated, ${renamedCount} promoted`;
899
+ if (skippedHtmlCount > 0) {
900
+ summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
901
+ }
902
+ progress.done('Auto-index', summary);
865
903
  } else {
866
904
  progress.log(`Auto-index: All folders already have index.html`);
867
905
  }