@docmd/plugin-search 0.4.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/LICENSE +21 -0
- package/README.md +7 -0
- package/assets/docmd-search.js +229 -0
- package/index.js +67 -0
- package/package.json +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 docmd (docmd.io)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Source file from the docmd project — https://github.com/docmd-io/docmd
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Client-side search functionality for docmd
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
(function() {
|
|
8
|
+
let miniSearch = null;
|
|
9
|
+
let isIndexLoaded = false;
|
|
10
|
+
let selectedIndex = -1; // Track keyboard selection
|
|
11
|
+
|
|
12
|
+
const searchModal = document.getElementById('docmd-search-modal');
|
|
13
|
+
const searchInput = document.getElementById('docmd-search-input');
|
|
14
|
+
const searchResults = document.getElementById('docmd-search-results');
|
|
15
|
+
|
|
16
|
+
const rawRoot = window.DOCMD_ROOT || './';
|
|
17
|
+
const ROOT_PATH = rawRoot.endsWith('/') ? rawRoot : rawRoot + '/';
|
|
18
|
+
|
|
19
|
+
if (!searchModal) return;
|
|
20
|
+
|
|
21
|
+
const emptyStateHtml = '<div class="search-initial">Type to start searching...</div>';
|
|
22
|
+
|
|
23
|
+
// 1. Open/Close Logic
|
|
24
|
+
function openSearch() {
|
|
25
|
+
searchModal.style.display = 'flex';
|
|
26
|
+
searchInput.focus();
|
|
27
|
+
|
|
28
|
+
if (!searchInput.value.trim()) {
|
|
29
|
+
searchResults.innerHTML = emptyStateHtml;
|
|
30
|
+
selectedIndex = -1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!isIndexLoaded) loadIndex();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function closeSearch() {
|
|
37
|
+
searchModal.style.display = 'none';
|
|
38
|
+
selectedIndex = -1; // Reset selection
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Keyboard Navigation & Shortcuts
|
|
42
|
+
document.addEventListener('keydown', (e) => {
|
|
43
|
+
// Open: Cmd+K / Ctrl+K
|
|
44
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
if (searchModal.style.display === 'flex') {
|
|
47
|
+
closeSearch();
|
|
48
|
+
} else {
|
|
49
|
+
openSearch();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Context: Only handle these if search is open
|
|
54
|
+
if (searchModal.style.display === 'flex') {
|
|
55
|
+
const items = searchResults.querySelectorAll('.search-result-item');
|
|
56
|
+
|
|
57
|
+
if (e.key === 'Escape') {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
closeSearch();
|
|
60
|
+
}
|
|
61
|
+
else if (e.key === 'ArrowDown') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
if (items.length === 0) return;
|
|
64
|
+
selectedIndex = (selectedIndex + 1) % items.length;
|
|
65
|
+
updateSelection(items);
|
|
66
|
+
}
|
|
67
|
+
else if (e.key === 'ArrowUp') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
if (items.length === 0) return;
|
|
70
|
+
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
|
71
|
+
updateSelection(items);
|
|
72
|
+
}
|
|
73
|
+
else if (e.key === 'Enter') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
if (selectedIndex >= 0 && items[selectedIndex]) {
|
|
76
|
+
items[selectedIndex].click();
|
|
77
|
+
} else if (items.length > 0) {
|
|
78
|
+
// If nothing selected but results exist, click the first one on Enter
|
|
79
|
+
items[0].click();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function updateSelection(items) {
|
|
86
|
+
items.forEach((item, index) => {
|
|
87
|
+
if (index === selectedIndex) {
|
|
88
|
+
item.classList.add('selected');
|
|
89
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
90
|
+
} else {
|
|
91
|
+
item.classList.remove('selected');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Click handlers
|
|
97
|
+
document.querySelectorAll('.docmd-search-trigger').forEach(btn => {
|
|
98
|
+
btn.addEventListener('click', openSearch);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
searchModal.addEventListener('click', (e) => {
|
|
102
|
+
if (e.target === searchModal) closeSearch();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 3. Index Loading Logic
|
|
106
|
+
async function loadIndex() {
|
|
107
|
+
try {
|
|
108
|
+
|
|
109
|
+
const indexUrl = `${ROOT_PATH}search-index.json`;
|
|
110
|
+
|
|
111
|
+
console.log('Fetching index from:', indexUrl); // Debug log
|
|
112
|
+
|
|
113
|
+
const response = await fetch(indexUrl);
|
|
114
|
+
|
|
115
|
+
// Check content type to prevent "Unexpected token <" error
|
|
116
|
+
const contentType = response.headers.get("content-type");
|
|
117
|
+
if (contentType && contentType.includes("text/html")) {
|
|
118
|
+
throw new Error("Server returned HTML instead of JSON. Check paths.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!response.ok) throw new Error(response.status);
|
|
122
|
+
|
|
123
|
+
const jsonString = await response.text();
|
|
124
|
+
|
|
125
|
+
miniSearch = MiniSearch.loadJSON(jsonString, {
|
|
126
|
+
fields: ['title', 'headings', 'text'],
|
|
127
|
+
storeFields: ['title', 'id', 'text'],
|
|
128
|
+
searchOptions: {
|
|
129
|
+
fuzzy: 0.2,
|
|
130
|
+
prefix: true,
|
|
131
|
+
boost: { title: 2, headings: 1.5 }
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
isIndexLoaded = true;
|
|
136
|
+
// console.log('Search index loaded');
|
|
137
|
+
|
|
138
|
+
if (searchInput.value.trim()) {
|
|
139
|
+
searchInput.dispatchEvent(new Event('input'));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error('Failed to load search index', e);
|
|
144
|
+
searchResults.innerHTML = '<div class="search-error">Failed to load search index.</div>';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Helper: Snippets (Same as before)
|
|
149
|
+
function getSnippet(text, query) {
|
|
150
|
+
if (!text) return '';
|
|
151
|
+
const terms = query.split(/\s+/).filter(t => t.length > 2);
|
|
152
|
+
const lowerText = text.toLowerCase();
|
|
153
|
+
let bestIndex = -1;
|
|
154
|
+
|
|
155
|
+
for (const term of terms) {
|
|
156
|
+
const idx = lowerText.indexOf(term.toLowerCase());
|
|
157
|
+
if (idx >= 0) { bestIndex = idx; break; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const contextSize = 60;
|
|
161
|
+
let start = 0;
|
|
162
|
+
let end = 120;
|
|
163
|
+
|
|
164
|
+
if (bestIndex >= 0) {
|
|
165
|
+
start = Math.max(0, bestIndex - contextSize);
|
|
166
|
+
end = Math.min(text.length, bestIndex + contextSize);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let snippet = text.substring(start, end);
|
|
170
|
+
if (start > 0) snippet = '...' + snippet;
|
|
171
|
+
if (end < text.length) snippet = snippet + '...';
|
|
172
|
+
|
|
173
|
+
const safeTerms = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
174
|
+
if (safeTerms) {
|
|
175
|
+
const highlightRegex = new RegExp(`(${safeTerms})`, 'gi');
|
|
176
|
+
snippet = snippet.replace(highlightRegex, '<mark>$1</mark>');
|
|
177
|
+
}
|
|
178
|
+
return snippet;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 4. Search Execution
|
|
182
|
+
searchInput.addEventListener('input', (e) => {
|
|
183
|
+
const query = e.target.value.trim();
|
|
184
|
+
selectedIndex = -1; // Reset selection on new input
|
|
185
|
+
|
|
186
|
+
if (!query) {
|
|
187
|
+
searchResults.innerHTML = emptyStateHtml;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!isIndexLoaded) return;
|
|
192
|
+
|
|
193
|
+
const results = miniSearch.search(query);
|
|
194
|
+
renderResults(results, query);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function renderResults(results, query) {
|
|
198
|
+
if (results.length === 0) {
|
|
199
|
+
searchResults.innerHTML = '<div class="search-no-results">No results found.</div>';
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const html = results.slice(0, 10).map((result, index) => {
|
|
204
|
+
const snippet = getSnippet(result.text, query);
|
|
205
|
+
|
|
206
|
+
const linkHref = `${ROOT_PATH}${result.id}`;
|
|
207
|
+
|
|
208
|
+
// Add data-index for mouse interaction tracking if needed
|
|
209
|
+
return `
|
|
210
|
+
<a href="${linkHref}" class="search-result-item" data-index="${index}" onclick="window.closeDocmdSearch()">
|
|
211
|
+
<div class="search-result-title">${result.title}</div>
|
|
212
|
+
<div class="search-result-preview">${snippet}</div>
|
|
213
|
+
</a>
|
|
214
|
+
`;
|
|
215
|
+
}).join('');
|
|
216
|
+
|
|
217
|
+
searchResults.innerHTML = html;
|
|
218
|
+
|
|
219
|
+
// Optional: Allow mouse hover to update selectedIndex
|
|
220
|
+
searchResults.querySelectorAll('.search-result-item').forEach((item, idx) => {
|
|
221
|
+
item.addEventListener('mouseenter', () => {
|
|
222
|
+
selectedIndex = idx;
|
|
223
|
+
updateSelection(searchResults.querySelectorAll('.search-result-item'));
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
window.closeDocmdSearch = closeSearch;
|
|
229
|
+
})();
|
package/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs/promises');
|
|
3
|
+
const MiniSearch = require('minisearch');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to run after HTML generation is complete.
|
|
7
|
+
* @param {Object} context - { config, pages, outputDir, log }
|
|
8
|
+
*/
|
|
9
|
+
async function onPostBuild({ config, pages, outputDir, log }) {
|
|
10
|
+
if (config.search === false) return;
|
|
11
|
+
|
|
12
|
+
if(log) log('🔍 Generating search index...');
|
|
13
|
+
|
|
14
|
+
const searchData = [];
|
|
15
|
+
|
|
16
|
+
// Extract search data from processed pages
|
|
17
|
+
pages.forEach(page => {
|
|
18
|
+
// We expect 'page.searchData' to be populated by the Core processor
|
|
19
|
+
if (page.searchData) {
|
|
20
|
+
// Normalize ID to be the URL path
|
|
21
|
+
let pageId = page.outputPath.replace(/\\/g, '/');
|
|
22
|
+
if (pageId.endsWith('/index.html')) pageId = pageId.slice(0, -10);
|
|
23
|
+
if (pageId.endsWith('.html')) pageId = pageId.slice(0, -5);
|
|
24
|
+
|
|
25
|
+
searchData.push({
|
|
26
|
+
id: pageId,
|
|
27
|
+
title: page.searchData.title,
|
|
28
|
+
text: page.searchData.content,
|
|
29
|
+
headings: (page.searchData.headings || []).join(' ')
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const miniSearch = new MiniSearch({
|
|
35
|
+
fields: ['title', 'headings', 'text'],
|
|
36
|
+
storeFields: ['title', 'id', 'text'],
|
|
37
|
+
searchOptions: { boost: { title: 2, headings: 1.5 }, fuzzy: 0.2 }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
miniSearch.addAll(searchData);
|
|
41
|
+
|
|
42
|
+
const json = JSON.stringify(miniSearch.toJSON());
|
|
43
|
+
await fs.writeFile(path.join(outputDir, 'search-index.json'), json);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns path to client-side assets to be copied.
|
|
48
|
+
*/
|
|
49
|
+
function getAssets() {
|
|
50
|
+
return [
|
|
51
|
+
// 1. External Library (CDN)
|
|
52
|
+
{
|
|
53
|
+
url: 'https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.min.js',
|
|
54
|
+
type: 'js',
|
|
55
|
+
location: 'body' // Load at end of body
|
|
56
|
+
},
|
|
57
|
+
// 2. Local Logic (The glue code)
|
|
58
|
+
{
|
|
59
|
+
src: path.join(__dirname, 'assets/docmd-search.js'),
|
|
60
|
+
dest: 'assets/js/docmd-search.js', // Renamed for clarity
|
|
61
|
+
type: 'js',
|
|
62
|
+
location: 'body'
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { onPostBuild, getAssets };
|