diff --git a/package-lock.json b/package-lock.json index f61f239823..f29d71383d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11514,6 +11514,19 @@ "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", "license": "MIT" }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -18500,6 +18513,7 @@ "@fontsource/inter": "^5.2.8", "esbuild": "0.25.11", "fs-extra": "^11.2.0", + "fuse.js": "^7.3.0", "highlight.js": "^11.11.1", "html-entities": "^2.3.3", "jquery": "^4.0.0", diff --git a/src/docs/build.js b/src/docs/build.js index 3752c0c98c..b7de0e140d 100644 --- a/src/docs/build.js +++ b/src/docs/build.js @@ -840,14 +840,83 @@ IMPORTANT: when creating an app, include a link to 'https://developer.puter.com' fs.writeFileSync(outputFile, outputContent, 'utf8'); }; -function markdownToPlainText (markdown) { +function markdownToSearchData (markdown) { const html = marked.parse(markdown); const dom = new JSDOM(); const div = dom.window.document.createElement('div'); div.innerHTML = html; - return div.textContent.replace(/\s+/g, ' ').trim(); + const plainText = div.textContent.replace(/\s+/g, ' ').trim(); + + const headingsHTML = Array.from(div.querySelectorAll('h2, h3')).map(h => ({ + id: h.id, + text: h.textContent.replace(/\s+/g, ' ').trim() + })); + + let offset = 0; + const headings = []; + for (const h of headingsHTML) { + if (!h.text) continue; + const index = plainText.indexOf(h.text, offset); + if (index !== -1) { + headings.push({ slug: h.id, title: h.text, index }); + offset = index; + } + } + + return { text: plainText, headings }; +} + +function markdownToSearchSections(markdown, title, path) { + const html = marked.parse(markdown); + + const dom = new JSDOM(); + const div = dom.window.document.createElement('div'); + div.innerHTML = html; + + const sections = []; + + const headings = Array.from(div.querySelectorAll('h2, h3')); + + if (headings.length === 0) { + const plainText = div.textContent.replace(/\s+/g, ' ').trim(); + + return [{ + title, + path, + subheading: '', + subheadingTitle: '', + text: plainText, + }]; + } + + headings.forEach((heading, index) => { + let sectionText = heading.textContent; + + let current = heading.nextElementSibling; + + while ( + current && + current.tagName !== 'H2' && + current.tagName !== 'H3' + ) { + sectionText += ' ' + current.textContent; + current = current.nextElementSibling; + } + + sectionText = sectionText.replace(/\s+/g, ' ').trim(); + + sections.push({ + title, + path, + subheading: heading.id, + subheadingTitle: heading.textContent.trim(), + text: sectionText, + }); + }); + + return sections; } const generateSearchIndex = () => { @@ -857,21 +926,27 @@ const generateSearchIndex = () => { const indexFile = path.join(currentDir, 'src', 'index.md'); const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); - json.push({ - title: 'Puter.js', - path: '', - text: markdownToPlainText(indexMarkdown), - }); + const { content: indexContent } = parseFrontMatter(indexMarkdown); + json.push( + ...markdownToSearchSections( + indexContent, + 'Puter.js', + '' + ) + ); sidebar.forEach((item) => { if ( item.source ) { const file = path.join(currentDir, 'src', item.source); const markdown = fs.readFileSync(file, 'utf8'); - json.push({ - title: item.title_tag ?? item.title, - path: item.path, - text: markdownToPlainText(markdown), - }); + const { content } = parseFrontMatter(markdown); + json.push( + ...markdownToSearchSections( + content, + item.title_tag ?? item.title, + item.path + ) + ); } if ( item.children && Array.isArray(item.children) ) { @@ -879,11 +954,14 @@ const generateSearchIndex = () => { if ( child.source ) { const file = path.join(currentDir, 'src', child.source); const markdown = fs.readFileSync(file, 'utf8'); - json.push({ - title: child.title_tag ?? child.title, - path: child.path, - text: markdownToPlainText(markdown), - }); + const { content } = parseFrontMatter(markdown); + json.push( + ...markdownToSearchSections( + content, + child.title_tag ?? child.title, + child.path + ) + ); } }); } diff --git a/src/docs/package.json b/src/docs/package.json index f12f0690db..650e76aaa3 100644 --- a/src/docs/package.json +++ b/src/docs/package.json @@ -20,6 +20,7 @@ "@fontsource/inter": "^5.2.8", "esbuild": "0.25.11", "fs-extra": "^11.2.0", + "fuse.js": "^7.3.0", "highlight.js": "^11.11.1", "html-entities": "^2.3.3", "jquery": "^4.0.0", diff --git a/src/docs/src/assets/js/search.js b/src/docs/src/assets/js/search.js index 0a340fe8c9..2f1a658f61 100644 --- a/src/docs/src/assets/js/search.js +++ b/src/docs/src/assets/js/search.js @@ -1,9 +1,11 @@ import $ from 'jquery'; +import Fuse from 'fuse.js'; // Global search index let searchIndex = []; let searchTimeout = null; let selectedSearchResult = -1; +let fuseInstance = null; const commandIcon = ''; @@ -104,6 +106,18 @@ async function fetchSearchIndex () { const response = await fetch('/index.json'); const data = await response.json(); searchIndex = data; + fuseInstance = new Fuse(searchIndex, { + keys: [ + { name: 'title', weight: 2.0 }, + { name: 'text', weight: 1.0 } + ], + includeMatches: true, + includeScore: true, + threshold: 0.5, + ignoreLocation: true, + minMatchCharLength: 2, + + }); console.log('Search index loaded:', `${searchIndex.length } items`); } catch ( error ) { console.error('Failed to load search index:', error); @@ -127,115 +141,212 @@ function generateTextFragment (matchedText, prefix = '', suffix = '') { return `#:~:text=${encodedPrefix}${encodedText}${encodedSuffix}`; } +function highlightIndices(text, indices, offsetStart = 0, offsetEnd = text.length) { + let result = ''; + let currentIndex = offsetStart; + + // Filter and sort indices within the extracted window + let validIndices = indices + .filter(([start, end]) => start <= offsetEnd && end >= offsetStart) + .map(([start, end]) => [Math.max(start, offsetStart), Math.min(end, offsetEnd)]) + .sort((a, b) => a[0] - b[0]); + + // Expand to word boundaries to avoid highlighting single letters + validIndices = validIndices.map(([start, end]) => { + let s = start; + let e = end; + while (s > offsetStart && /[a-zA-Z0-9_]/.test(text[s - 1])) s--; + while (e < offsetEnd - 1 && /[a-zA-Z0-9_]/.test(text[e + 1])) e++; + return [s, e]; + }); + + // Merge overlapping/adjacent indices + const mergedIndices = []; + if (validIndices.length > 0) { + let current = [...validIndices[0]]; + for (let i = 1; i < validIndices.length; i++) { + if (validIndices[i][0] <= current[1] + 1) { + current[1] = Math.max(current[1], validIndices[i][1]); + } else { + mergedIndices.push(current); + current = [...validIndices[i]]; + } + } + mergedIndices.push(current); + } + + for (const [start, end] of mergedIndices) { + if (start > currentIndex) { + result += escapeHtml(text.substring(currentIndex, start)); + } + result += '' + escapeHtml(text.substring(start, end + 1)) + ''; + currentIndex = end + 1; + } + + if (currentIndex < offsetEnd) { + result += escapeHtml(text.substring(currentIndex, offsetEnd)); + } + + return result; +} + function performSearch (query) { if ( !query || query.length < 2 ) { - $('.search-results').html( - '
Start typing to search...
'); + $('.search-results').html('
Start typing to search...
'); return; } - const titleResults = []; - const textResults = []; - const queryLower = query.toLowerCase(); - - searchIndex.forEach((item) => { - const titleMatch = item.title.toLowerCase().indexOf(queryLower); - if ( titleMatch !== -1 ) { - const highlightedTitle = escapeHtml(item.title).replace( - new RegExp(`(${escapeHtml(query)})`, 'i'), - '$1'); - - titleResults.push({ - title: highlightedTitle, - path: item.path, - text: escapeHtml(item.text.substring(0, 60) + (item.text.length > 60 ? '...' : '')), - textFragment: '', - }); - } + if (!fuseInstance) return; + + const queryLower = query.toLowerCase().trim(); + const queryTokens = queryLower.split(/\s+/).filter(Boolean); + const fuseResults = fuseInstance.search(query); + const finalResults = []; + fuseResults.forEach(result => { + if (result.score > 0.7) { + return; + } + const item = result.item; const textLower = item.text.toLowerCase(); - let searchOffset = 0; - - // Find all matches in the text - while ( true ) { - const textMatch = textLower.indexOf(queryLower, searchOffset); - if ( textMatch === -1 ) break; - - // Extract 50 chars before and after the match - const contextStart = Math.max(0, textMatch - 50); - const contextEnd = Math.min(item.text.length, - textMatch + query.length + 50); - const contextText = item.text.substring(contextStart, contextEnd); - - // Split into words - const words = contextText.split(/\s+/); - - // Find all words that intersect with the match range - const matchStart = textMatch; - const matchEnd = textMatch + query.length; - let matchStartWordIndex = -1; - let matchEndWordIndex = -1; - let currentPos = contextStart; - - for ( let i = 0; i < words.length; i++ ) { - const wordStart = currentPos; - const wordEnd = wordStart + words[i].length; - - // Check if this word intersects with the match - if ( wordStart < matchEnd && wordEnd > matchStart ) { - if ( matchStartWordIndex === -1 ) { - matchStartWordIndex = i; + const titleLower = item.title.toLowerCase(); + + let score = (1 - result.score) * 100; // Base fuse score (0-100) + + // Exact matches + let isExactTextMatch = textLower.includes(queryLower); + let isExactTitleMatch = titleLower.includes(queryLower); + + if (isExactTitleMatch) score += 500; + if (isExactTextMatch) score += 300; + + // Near phrase / All Keywords + if (!isExactTextMatch && !isExactTitleMatch) { + let allTokensInTitle = queryTokens.length > 0 && queryTokens.every(t => titleLower.includes(t)); + let allTokensInText = queryTokens.length > 0 && queryTokens.every(t => textLower.includes(t)); + if (allTokensInTitle) score += 200; + if (allTokensInText) score += 100; + } + + let occurrences = []; + + if (isExactTextMatch) { + let offset = 0; + while (true) { + let idx = textLower.indexOf(queryLower, offset); + if (idx === -1) break; + occurrences.push({ type: 'exact', index: idx, length: queryLower.length }); + offset = idx + queryLower.length; + if (occurrences.length >= 3) break; // Limit exact matches per page + } + } + + if (occurrences.length === 0 && result.matches) { + const textMatch = result.matches.find(m => m.key === 'text'); + if (textMatch && textMatch.indices.length > 0) { + const firstIndex = textMatch.indices[0][0]; + occurrences.push({ type: 'fuzzy', index: firstIndex, indices: textMatch.indices }); + } + } + + // Highlight Title + let highlightedTitle = item.title; + const titleMatch = result.matches ? result.matches.find(m => m.key === 'title') : null; + if (isExactTitleMatch) { + const exactTitleMatchIndex = titleLower.indexOf(queryLower); + highlightedTitle = highlightIndices(item.title, [[exactTitleMatchIndex, exactTitleMatchIndex + queryLower.length - 1]]); + } else if (titleMatch) { + highlightedTitle = highlightIndices(item.title, titleMatch.indices); + } else { + highlightedTitle = escapeHtml(item.title); + } + + if (occurrences.length === 0) { + finalResults.push({ + title: highlightedTitle, + path: item.path, + text: escapeHtml(item.text.substring(0, 80)) + '...', + textFragment: '', + score: score + 50 + }); + } + + occurrences.forEach((occ, i) => { + let contextStart = Math.max(0, occ.index - 50); + let contextEnd = Math.min(item.text.length, occ.index + (occ.length || 20) + 50); + + // Snap to word boundaries + while (contextStart > 0 && !/\s/.test(item.text[contextStart - 1])) contextStart--; + while (contextEnd < item.text.length && !/\s/.test(item.text[contextEnd])) contextEnd++; + + let contextText = item.text.substring(contextStart, contextEnd); + let textFragment = ''; + let highlightedChunk = ''; + + if (occ.type === 'exact') { + const exactMatchStart = occ.index; + const exactMatchEnd = occ.index + occ.length; + const words = contextText.split(/\s+/); + let currentPos = contextStart; + let matchStartWordIndex = -1; + let matchEndWordIndex = -1; + + for (let j = 0; j < words.length; j++) { + const wordStart = currentPos; + const wordEnd = wordStart + words[j].length; + if (wordStart < exactMatchEnd && wordEnd > exactMatchStart) { + if (matchStartWordIndex === -1) matchStartWordIndex = j; + matchEndWordIndex = j; + } + currentPos = wordEnd + 1; // +1 for space + } + + const matchedWords = matchStartWordIndex !== -1 + ? words.slice(matchStartWordIndex, matchEndWordIndex + 1).join(' ') + : words[0] || ''; + + const fragmentPrefix = matchStartWordIndex > 0 ? words[matchStartWordIndex - 1] : ''; + const fragmentSuffix = matchEndWordIndex < words.length - 1 ? words[matchEndWordIndex + 1] : ''; + + textFragment = generateTextFragment(matchedWords, fragmentPrefix, fragmentSuffix); + highlightedChunk = highlightIndices(item.text, [[occ.index, occ.index + occ.length - 1]], contextStart, contextEnd); + } else { + let nearestHeading = null; + + if (item.headings && item.headings.length > 0) { + for (let j = item.headings.length - 1; j >= 0; j--) { + if (item.headings[j].index <= occ.index) { + nearestHeading = item.headings[j]; + break; } - matchEndWordIndex = i; } - currentPos = wordEnd + 1; // +1 for space } - // Get the complete matched text (all words that contain the match) - const matchedWords = - matchStartWordIndex !== -1 - ? words.slice(matchStartWordIndex, matchEndWordIndex + 1).join(' ') - : words[0] || ''; - - // Get prefix and suffix for text fragment (closest words) - const fragmentPrefix = - matchStartWordIndex > 0 ? words[matchStartWordIndex - 1] : ''; - const fragmentSuffix = - matchEndWordIndex < words.length - 1 - ? words[matchEndWordIndex + 1] - : ''; - - // Generate text fragment - const textFragment = generateTextFragment(matchedWords, - fragmentPrefix, - fragmentSuffix); - - // Create display text (max 4 words before/after) - const startWord = Math.max(0, matchStartWordIndex - 4); - const endWord = Math.min(words.length, matchEndWordIndex + 5); - const displayWords = words.slice(startWord, endWord); - - let displayText = displayWords.join(' '); - if ( startWord > 0 ) displayText = `...${ displayText}`; - if ( endWord < words.length ) displayText = `${displayText }...`; - - // Highlight the matched text in display - const highlightedChunk = escapeHtml(displayText).replace( - new RegExp(`(${escapeHtml(query)})`, 'i'), - '$1'); - - textResults.push({ - title: item.title, - path: item.path, - text: highlightedChunk, - textFragment: textFragment, - }); - - searchOffset = textMatch + 1; + if (nearestHeading) { + textFragment = `#${nearestHeading.slug}`; + } + + // For fuzzy matches, avoid highlighting Fuse character ranges + highlightedChunk = escapeHtml( + item.text.substring(contextStart, contextEnd) + ); } + + if (contextStart > 0) highlightedChunk = '...' + highlightedChunk; + if (contextEnd < item.text.length) highlightedChunk = highlightedChunk + '...'; + + finalResults.push({ + title: highlightedTitle, + path: item.path, + text: highlightedChunk, + textFragment: textFragment, + score: score - i // slight penalty for subsequent occurrences + }); + }); }); - updateSearchResults([...titleResults, ...textResults]); + finalResults.sort((a, b) => b.score - a.score); + updateSearchResults(finalResults); } function updateSearchResults (results) { diff --git a/src/gui/src/UI/UIPrompt.js b/src/gui/src/UI/UIPrompt.js index bf284e47e1..9a4c394203 100644 --- a/src/gui/src/UI/UIPrompt.js +++ b/src/gui/src/UI/UIPrompt.js @@ -47,7 +47,11 @@ function UIPrompt (options) { h += `
${html_encode(options.message)}
`; // prompt h += '
'; + + h += ``; + h += ``; + h += '
'; // buttons if ( options.buttons && options.buttons.length > 0 ) {