@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 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,7 @@
1
+ # @docmd/plugin-search
2
+
3
+ Adds offline, full-text search to **docmd** sites.
4
+
5
+ - **Zero Config:** Enabled by default.
6
+ - **Offline:** Generates a `search-index.json` at build time.
7
+ - **Fast:** Uses `minisearch` for fuzzy matching.
@@ -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 };
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@docmd/plugin-search",
3
+ "version": "0.4.0",
4
+ "description": "Offline full-text search for docmd",
5
+ "main": "index.js",
6
+ "dependencies": {
7
+ "minisearch": "^7.2.0"
8
+ },
9
+ "license": "MIT"
10
+ }