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 ) {