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/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..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'; @@ -796,56 +797,135 @@ IMPORTANT: when creating an app, include a link to 'https://developer.puter.com' fs.writeFileSync(outputFile, outputContent, 'utf8'); }; -function markdownToPlainText (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; - return div.textContent.replace(/\s+/g, ' ').trim(); + const sections = []; + let currentSection = { + title: pageTitle, + path: pagePath, + subheading: '', + subheadingTitle: '', + textNodes: [], + headingsHTML: [] + }; + + 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 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 = () => { const currentDir = process.cwd(); const outputFile = path.join(currentDir, 'dist', 'index.json'); - const json = []; + const documents = []; const indexFile = path.join(currentDir, 'src', 'index.md'); - const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); - json.push({ - title: 'Puter.js', - path: '', - text: markdownToPlainText(indexMarkdown), - }); + if (fs.existsSync(indexFile)) { + const indexMarkdown = fs.readFileSync(indexFile, 'utf8'); + const { content: indexContent } = parseFrontMatter(indexMarkdown); + documents.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), - }); + if (fs.existsSync(file)) { + const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); + documents.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'); - json.push({ - title: child.title_tag ?? child.title, - path: child.path, - text: markdownToPlainText(markdown), - }); + if (fs.existsSync(file)) { + const markdown = fs.readFileSync(file, 'utf8'); + const { content } = parseFrontMatter(markdown); + 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/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/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 0a340fe8c9..8561aecae0 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 = ''; @@ -99,11 +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; + searchIndex = data.documents; + + // Load the pre-built Fuse index generated at build time. + // Reference: https://www.fusejs.io/api/indexing.html + const index = Fuse.parseIndex(data.index); + fuseInstance = new Fuse(searchIndex, fuseOptions, index); + console.log('Search index loaded:', `${searchIndex.length } items`); } catch ( error ) { console.error('Failed to load search index:', error); @@ -127,115 +151,228 @@ 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( - '