From 566842d030dcf3a16cf042a843c7ca68b11f0ea8 Mon Sep 17 00:00:00 2001 From: Satyansh Chand Date: Thu, 5 Mar 2026 01:23:04 +0530 Subject: [PATCH 1/4] fix(docs-search): restore fragment navigation and improve fuzzy search --- package-lock.json | 10 ++ src/docs/build.js | 31 +++- src/docs/package.json | 1 + src/docs/src/assets/js/search.js | 295 +++++++++++++++++++++---------- 4 files changed, 235 insertions(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index dd824cc0a0..fcb821617f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12429,6 +12429,15 @@ "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", "license": "MIT" }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -21422,6 +21431,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "fs-extra": "^11.2.0", + "fuse.js": "^7.1.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 0b50a947c0..afa341f3fb 100644 --- a/src/docs/build.js +++ b/src/docs/build.js @@ -796,14 +796,32 @@ 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 }; } const generateSearchIndex = () => { @@ -813,20 +831,22 @@ const generateSearchIndex = () => { const indexFile = path.join(currentDir, 'src', 'index.md'); const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); + const { content: indexContent } = parseFrontMatter(indexMarkdown); json.push({ title: 'Puter.js', path: '', - text: markdownToPlainText(indexMarkdown), + ...markdownToSearchData(indexContent), }); sidebar.forEach((item) => { if ( item.source ) { const file = path.join(currentDir, 'src', item.source); const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); json.push({ title: item.title_tag ?? item.title, path: item.path, - text: markdownToPlainText(markdown), + ...markdownToSearchData(content), }); } @@ -835,10 +855,11 @@ const generateSearchIndex = () => { if ( child.source ) { const file = path.join(currentDir, 'src', child.source); const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); json.push({ title: child.title_tag ?? child.title, path: child.path, - text: markdownToPlainText(markdown), + ...markdownToSearchData(content), }); } }); diff --git a/src/docs/package.json b/src/docs/package.json index 64bbb24c52..3c14e9ce70 100644 --- a/src/docs/package.json +++ b/src/docs/package.json @@ -16,6 +16,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "fs-extra": "^11.2.0", + "fuse.js": "^7.1.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..b4e4dc8062 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,17 @@ 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.4, + ignoreLocation: true, + minMatchCharLength: 2, + }); console.log('Search index loaded:', `${searchIndex.length } items`); } catch ( error ) { console.error('Failed to load search index:', error); @@ -127,115 +140,203 @@ 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 => { + 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; - } - matchEndWordIndex = i; - } - currentPos = wordEnd + 1; // +1 for space + 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 }); } + } - // 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; + // 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; + } + } + } + if (nearestHeading) { + textFragment = `#${nearestHeading.slug}`; + } + highlightedChunk = highlightIndices(item.text, occ.indices, 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) { From 233a89d42d66c9c5baa2bcbf51e5136e2ed6fa82 Mon Sep 17 00:00:00 2001 From: Satyansh Chand Date: Sat, 7 Mar 2026 20:27:47 +0530 Subject: [PATCH 2/4] improve docs search indexing and fuzzy navigation --- .gitignore | 1 + src/docs/build.js | 121 +++++++++++++++++++++---------- src/docs/src/assets/js/search.js | 2 + 3 files changed, 87 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index cd66e7e28b..3061c0705c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ license-header.txt # Build Outputs dist/ +src/docs/dist/ # VS Code IDE .vscode/**/* diff --git a/src/docs/build.js b/src/docs/build.js index afa341f3fb..0da493e493 100644 --- a/src/docs/build.js +++ b/src/docs/build.js @@ -796,32 +796,85 @@ IMPORTANT: when creating an app, include a link to 'https://developer.puter.com' fs.writeFileSync(outputFile, outputContent, 'utf8'); }; -function markdownToSearchData (markdown) { +function markdownToSearchSections (markdown, pageTitle, pagePath) { const html = marked.parse(markdown); const dom = new JSDOM(); const div = dom.window.document.createElement('div'); div.innerHTML = html; - 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() - })); + const sections = []; + let currentSection = { + title: pageTitle, + path: pagePath, + subheading: '', + subheadingTitle: '', + textNodes: [], + headingsHTML: [] + }; - 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; + const childNodes = Array.from(div.childNodes); + for (const node of childNodes) { + if (node.nodeName.toLowerCase() === 'h2') { + if (currentSection.textNodes.length > 0 || currentSection.headingsHTML.length > 0) { + sections.push(currentSection); + } + currentSection = { + title: pageTitle, + path: pagePath, + subheading: node.id, + subheadingTitle: node.textContent.replace(/\s+/g, ' ').trim(), + textNodes: [], + headingsHTML: [] + }; + } + + currentSection.textNodes.push(node); + if (node.nodeName.toLowerCase() === 'h2' || node.nodeName.toLowerCase() === 'h3') { + currentSection.headingsHTML.push({ + id: node.id, + text: node.textContent.replace(/\s+/g, ' ').trim() + }); + } else if (node.querySelectorAll) { + const headings = Array.from(node.querySelectorAll('h2, h3')); + for (const h of headings) { + currentSection.headingsHTML.push({ + id: h.id, + text: h.textContent.replace(/\s+/g, ' ').trim() + }); + } } } + + if (currentSection.textNodes.length > 0 || currentSection.headingsHTML.length > 0) { + sections.push(currentSection); + } - return { text: plainText, headings }; + return sections.map(sec => { + const secDiv = dom.window.document.createElement('div'); + sec.textNodes.forEach(n => secDiv.appendChild(n.cloneNode(true))); + const plainText = secDiv.textContent.replace(/\s+/g, ' ').trim(); + + let offset = 0; + const headings = []; + for (const h of sec.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 { + title: sec.title, + path: sec.path, + subheading: sec.subheading, + subheadingTitle: sec.subheadingTitle, + text: plainText, + headings: headings + }; + }).filter(sec => sec.text.trim().length > 0); } const generateSearchIndex = () => { @@ -830,37 +883,31 @@ const generateSearchIndex = () => { const json = []; const indexFile = path.join(currentDir, 'src', 'index.md'); - const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); - const { content: indexContent } = parseFrontMatter(indexMarkdown); - json.push({ - title: 'Puter.js', - path: '', - ...markdownToSearchData(indexContent), - }); + if (fs.existsSync(indexFile)) { + const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); + 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'); - const { content } = parseFrontMatter(markdown); - json.push({ - title: item.title_tag ?? item.title, - path: item.path, - ...markdownToSearchData(content), - }); + if (fs.existsSync(file)) { + const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); + json.push(...markdownToSearchSections(content, item.title_tag ?? item.title, item.path)); + } } if ( item.children && Array.isArray(item.children) ) { item.children.forEach((child) => { if ( child.source ) { const file = path.join(currentDir, 'src', child.source); - const markdown = fs.readFileSync(file, 'utf8'); - const { content } = parseFrontMatter(markdown); - json.push({ - title: child.title_tag ?? child.title, - path: child.path, - ...markdownToSearchData(content), - }); + if (fs.existsSync(file)) { + const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); + json.push(...markdownToSearchSections(content, child.title_tag ?? child.title, child.path)); + } } }); } diff --git a/src/docs/src/assets/js/search.js b/src/docs/src/assets/js/search.js index b4e4dc8062..5fd322661e 100644 --- a/src/docs/src/assets/js/search.js +++ b/src/docs/src/assets/js/search.js @@ -318,6 +318,8 @@ function performSearch (query) { } if (nearestHeading) { textFragment = `#${nearestHeading.slug}`; + } else if (item.subheading) { + textFragment = `#${item.subheading}`; } highlightedChunk = highlightIndices(item.text, occ.indices, contextStart, contextEnd); } From 85a36022f5a81269630685a0283eb1eb685a14af Mon Sep 17 00:00:00 2001 From: Satyansh Chand Date: Mon, 9 Mar 2026 21:44:18 +0530 Subject: [PATCH 3/4] use Fuse index and improve docs search highlighting --- src/docs/src/assets/css/style.css | 9 ++++- src/docs/src/assets/js/search.js | 58 ++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/docs/src/assets/css/style.css b/src/docs/src/assets/css/style.css index a7f0de2306..2d5d0dd6ec 100755 --- a/src/docs/src/assets/css/style.css +++ b/src/docs/src/assets/css/style.css @@ -429,10 +429,17 @@ ul code { .search-result mark { background-color: #fff3cd; - padding: 0; + color: #333; + padding: 0 1px; border-radius: 2px; } +/* Dark-mode highlight for matched text in search results */ +.dark-mode .search-result mark { + background-color: #534a1e; + color: #f0e6b2; +} + .search-no-results { padding: 20px; text-align: center; diff --git a/src/docs/src/assets/js/search.js b/src/docs/src/assets/js/search.js index 5fd322661e..35f447ed23 100644 --- a/src/docs/src/assets/js/search.js +++ b/src/docs/src/assets/js/search.js @@ -101,22 +101,33 @@ $(document).ready(function () { fetchSearchIndex(); }); +// Shared Fuse search keys and options, kept in one place so the +// index and instance always stay in sync. +const fuseKeys = [ + { name: 'title', weight: 2.0 }, + { name: 'text', weight: 1.0 } +]; + +const fuseOptions = { + keys: fuseKeys, + includeMatches: true, + includeScore: true, + threshold: 0.4, + ignoreLocation: true, + minMatchCharLength: 2, +}; + async function fetchSearchIndex () { try { 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.4, - ignoreLocation: true, - minMatchCharLength: 2, - }); + + // Prebuild a Fuse index for better performance on larger datasets. + // Reference: https://www.fusejs.io/api/indexing.html + const index = Fuse.createIndex(fuseKeys, searchIndex); + fuseInstance = new Fuse(searchIndex, fuseOptions, index); + console.log('Search index loaded:', `${searchIndex.length } items`); } catch ( error ) { console.error('Failed to load search index:', error); @@ -258,10 +269,33 @@ function performSearch (query) { } if (occurrences.length === 0) { + // No exact or fuzzy text match — show the first 80 chars of the + // page body. We still attempt to highlight any query tokens that + // happen to appear in this preview snippet so the result feels + // consistent with the other highlighted entries. + const snippetEnd = Math.min(item.text.length, 80); + let snippetHTML; + const snippetLower = item.text.substring(0, snippetEnd).toLowerCase(); + const tokenIndices = []; + for (const token of queryTokens) { + let pos = 0; + while (pos < snippetEnd) { + const idx = snippetLower.indexOf(token, pos); + if (idx === -1 || idx >= snippetEnd) break; + tokenIndices.push([idx, idx + token.length - 1]); + pos = idx + token.length; + } + } + if (tokenIndices.length > 0) { + snippetHTML = highlightIndices(item.text, tokenIndices, 0, snippetEnd) + '...'; + } else { + snippetHTML = escapeHtml(item.text.substring(0, snippetEnd)) + '...'; + } + finalResults.push({ title: highlightedTitle, path: item.path, - text: escapeHtml(item.text.substring(0, 80)) + '...', + text: snippetHTML, textFragment: '', score: score + 50 }); From a309e78d6a4732358ad3a4c15f0c0321c61ed5b2 Mon Sep 17 00:00:00 2001 From: Satyansh Chand Date: Tue, 10 Mar 2026 19:59:03 +0530 Subject: [PATCH 4/4] generate Fuse.js search index at build time and load with Fuse.parseIndex --- src/docs/build.js | 22 +++++++++++++++++----- src/docs/src/assets/js/search.js | 6 +++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/docs/build.js b/src/docs/build.js index 0da493e493..1d1afb600f 100644 --- a/src/docs/build.js +++ b/src/docs/build.js @@ -10,6 +10,7 @@ const { JSDOM } = require('jsdom'); const yaml = require('js-yaml'); const esbuild = require('esbuild'); const { generatePlayground } = require('./src/playground'); +const Fuse = require('fuse.js'); const site = 'https://docs.puter.com'; @@ -880,13 +881,13 @@ function markdownToSearchSections (markdown, pageTitle, pagePath) { const generateSearchIndex = () => { const currentDir = process.cwd(); const outputFile = path.join(currentDir, 'dist', 'index.json'); - const json = []; + const documents = []; const indexFile = path.join(currentDir, 'src', 'index.md'); if (fs.existsSync(indexFile)) { const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); const { content: indexContent } = parseFrontMatter(indexMarkdown); - json.push(...markdownToSearchSections(indexContent, 'Puter.js', '')); + documents.push(...markdownToSearchSections(indexContent, 'Puter.js', '')); } sidebar.forEach((item) => { @@ -895,7 +896,7 @@ const generateSearchIndex = () => { if (fs.existsSync(file)) { const markdown = fs.readFileSync(file, 'utf8'); const { content } = parseFrontMatter(markdown); - json.push(...markdownToSearchSections(content, item.title_tag ?? item.title, item.path)); + documents.push(...markdownToSearchSections(content, item.title_tag ?? item.title, item.path)); } } @@ -906,14 +907,25 @@ const generateSearchIndex = () => { if (fs.existsSync(file)) { const markdown = fs.readFileSync(file, 'utf8'); const { content } = parseFrontMatter(markdown); - json.push(...markdownToSearchSections(content, child.title_tag ?? child.title, child.path)); + documents.push(...markdownToSearchSections(content, child.title_tag ?? child.title, child.path)); } } }); } }); - fs.writeFileSync(outputFile, JSON.stringify(json), 'utf8'); + // Generate the Fuse.js index at build time so the client can skip + // the indexing step via Fuse.parseIndex(). + const fuseKeys = [ + { name: 'title', weight: 2.0 }, + { name: 'text', weight: 1.0 } + ]; + const fuseIndex = Fuse.createIndex(fuseKeys, documents); + + fs.writeFileSync(outputFile, JSON.stringify({ + documents, + index: fuseIndex.toJSON() + }), 'utf8'); }; // Main execution diff --git a/src/docs/src/assets/js/search.js b/src/docs/src/assets/js/search.js index 35f447ed23..8561aecae0 100644 --- a/src/docs/src/assets/js/search.js +++ b/src/docs/src/assets/js/search.js @@ -121,11 +121,11 @@ async function fetchSearchIndex () { try { const response = await fetch('/index.json'); const data = await response.json(); - searchIndex = data; + searchIndex = data.documents; - // Prebuild a Fuse index for better performance on larger datasets. + // Load the pre-built Fuse index generated at build time. // Reference: https://www.fusejs.io/api/indexing.html - const index = Fuse.createIndex(fuseKeys, searchIndex); + const index = Fuse.parseIndex(data.index); fuseInstance = new Fuse(searchIndex, fuseOptions, index); console.log('Search index loaded:', `${searchIndex.length } items`);