From bc7ae6311bd8f276bb4b3acbecbb99fe8495bea8 Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Fri, 22 May 2026 14:42:26 +0800 Subject: [PATCH 1/3] fix: improve data saving logic in search.js --- src/plugins/search/search.js | 45 ++++++++++--- test/e2e/search.test.js | 124 +++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 9 deletions(-) diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index 38c074b02d..22538b9000 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -16,10 +16,29 @@ db.version(1).stores({ }); async function saveData(maxAge, expireKey) { - INDEXES = Object.values(INDEXES).flatMap(innerData => - Object.values(innerData), - ); - await /** @type {any} */ (db).search.bulkPut(INDEXES); + const records = []; + + Object.values(INDEXES).forEach(entry => { + if (!entry || typeof entry !== 'object') { + return; + } + + // Entry may already be a flat record read from IndexedDB. + if ('slug' in entry) { + records.push(entry); + return; + } + + // Entry may be a per-path map of slug -> record produced by genIndex(). + Object.values(entry).forEach(item => { + if (item && typeof item === 'object' && 'slug' in item) { + records.push(item); + } + }); + }); + + INDEXES = records; + await /** @type {any} */ (db).search.bulkPut(records); await /** @type {any} */ (db).expires.put({ key: expireKey, value: Date.now() + maxAge, @@ -306,16 +325,23 @@ export async function init(config, vm) { const len = paths.length; let count = 0; + const markComplete = async () => { + if (len === ++count) { + await saveData(config.maxAge, expireKey); + } + }; + paths.forEach(path => { const pathExists = Array.isArray(INDEXES) ? INDEXES.some(obj => obj.path === path) : false; if (pathExists) { - return count++; + void markComplete(); + return; } Docsify.get(vm.router.getFile(path), false, vm.config.requestHeaders).then( - async result => { + result => { INDEXES[path] = genIndex( path, result, @@ -323,9 +349,10 @@ export async function init(config, vm) { config.depth, indexKey, ); - if (len === ++count) { - await saveData(config.maxAge, expireKey); - } + return markComplete(); + }, + () => { + return markComplete(); }, ); }); diff --git a/test/e2e/search.test.js b/test/e2e/search.test.js index a99e0121a2..f6b02191d7 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -198,6 +198,130 @@ test.describe('Search Plugin Tests', () => { await expect(resultsHeadingElm).toHaveText('EmptyContent'); }); + test('keeps saving index when one auto path request fails with cached records', async ({ + page, + }) => { + const indexKey = 'docsify.search.index'; + const expireKey = 'docsify.search.expires'; + + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + + await page.evaluate( + ({ indexKey, expireKey }) => { + return new Promise((resolve, reject) => { + const request = indexedDB.open('docsify', 1); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains('search')) { + db.createObjectStore('search', { keyPath: 'slug' }); + } + + if (!db.objectStoreNames.contains('expires')) { + db.createObjectStore('expires', { keyPath: 'key' }); + } + }; + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['search', 'expires'], 'readwrite'); + + tx.objectStore('search').put({ + slug: '/cached', + title: 'Cached Page', + body: 'cached record', + path: '/cached', + indexKey, + }); + tx.objectStore('expires').put({ + key: expireKey, + value: Date.now() + 60 * 1000, + }); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + }); + }, + { indexKey, expireKey }, + ); + + await docsifyInit({ + markdown: { + homepage: '# Home', + sidebar: ` + - [Cached](cached) + - [Success](success) + - [Fail](fail) + `, + }, + routes: { + '/success.md': '# Success\n\nregressionKeyword', + '/fail.md': { + status: 404, + body: 'Not Found', + contentType: 'text/markdown', + }, + }, + scriptURLs: ['/dist/plugins/search.js'], + }); + + await expect + .poll(async () => { + return await page.evaluate(indexKey => { + return new Promise((resolve, reject) => { + const request = indexedDB.open('docsify'); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['search', 'expires'], 'readonly'); + const searchStore = tx.objectStore('search'); + const expiresStore = tx.objectStore('expires'); + const searchReq = searchStore.getAll(); + const expiresReq = expiresStore.get('docsify.search.expires'); + + tx.onerror = () => reject(tx.error); + tx.oncomplete = () => { + const records = Array.isArray(searchReq.result) + ? searchReq.result + : []; + const hasSuccessRecord = records.some( + record => + record && + record.indexKey === indexKey && + record.path === '/success', + ); + const hasInvalidRecord = records.some( + record => !record || typeof record.slug !== 'string', + ); + const hasExpireRecord = Boolean(expiresReq.result?.value); + + db.close(); + resolve( + hasSuccessRecord && hasExpireRecord && !hasInvalidRecord, + ); + }; + }; + }); + }, indexKey); + }) + .toBe(true); + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await searchFieldElm.fill('regressionKeyword'); + await expect(resultsHeadingElm).toHaveText('Success'); + expect(pageErrors).toEqual([]); + }); + test('handles default focusSearch binding', async ({ page }) => { const docsifyInitConfig = { scriptURLs: ['/dist/plugins/search.js'], From 34fa6a3b4d4120300c0f5cf4c2e4d8d484cc6f59 Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Tue, 26 May 2026 11:33:22 +0800 Subject: [PATCH 2/3] fix: implement embedded content retrieval and indexing in search.js --- src/plugins/search/search.js | 120 +++++++++++++++++++++++++++++++---- test/e2e/search.test.js | 56 +++++++++++++++- 2 files changed, 164 insertions(+), 12 deletions(-) diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index 22538b9000..41b83acc24 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -4,6 +4,7 @@ import { removeAtag, escapeHtml, } from '../../core/render/utils.js'; +import { getPath, getParentPath, isAbsolutePath } from '../../core/router/util.js'; import { markdownToTxt } from './markdown-to-txt.js'; import Dexie from 'dexie'; @@ -115,6 +116,104 @@ function getListData(token) { return token.text; } +function extractFragmentContent(text, fragment, fullLine) { + if (!fragment) { + return text; + } + + let fragmentRegex = `(?:###|\\/\\/\\/)\\s*\\[${fragment}\\]`; + if (fullLine) { + fragmentRegex = `.*${fragmentRegex}.*\n`; + } + + const pattern = new RegExp(`(?:${fragmentRegex})([\\s\\S]*?)(?:${fragmentRegex})`); + const match = text.match(pattern); + return ((match || [])[1] || '').trim(); +} + +function collectEmbedRequests(raw = '', path, vm) { + const tokens = window.marked.lexer(raw); + const requests = []; + + const maybePushEmbed = inlineToken => { + if (!inlineToken || (inlineToken.type !== 'link' && inlineToken.type !== 'image')) { + return; + } + + const { config } = getAndRemoveConfig(inlineToken.title || ''); + if (!config.include || !inlineToken.href) { + return; + } + + const href = isAbsolutePath(inlineToken.href) + ? inlineToken.href + : getPath(vm.router.getBasePath(), getParentPath(path), inlineToken.href); + + let type = 'code'; + if (/\.(md|markdown)/.test(href)) { + type = 'markdown'; + } else if (/\.mmd/.test(href)) { + type = 'mermaid'; + } + + requests.push({ + url: href, + type, + fragment: config.fragment, + omitFragmentLine: config.omitFragmentLine, + }); + }; + + tokens.forEach(token => { + if (token.type === 'paragraph') { + (token.tokens || []).forEach(maybePushEmbed); + } else if (token.type === 'table') { + (token.header || []).forEach(cell => { + (cell.tokens || []).forEach(maybePushEmbed); + }); + (token.rows || []).forEach(row => { + row.forEach(cell => { + (cell.tokens || []).forEach(maybePushEmbed); + }); + }); + } + }); + + return requests; +} + +async function getEmbeddedContent(raw = '', path, vm) { + const requests = collectEmbedRequests(raw, path, vm); + if (!requests.length) { + return ''; + } + + const results = await Promise.all( + requests.map( + request => + new Promise(resolve => { + Docsify.get(request.url, false, vm.config.requestHeaders).then( + text => { + let content = text || ''; + if (request.fragment) { + content = extractFragmentContent( + content, + request.fragment, + request.omitFragmentLine, + ); + } + + resolve(request.type === 'markdown' ? content : markdownToTxt(content)); + }, + () => resolve(''), + ); + }), + ), + ); + + return results.filter(Boolean).join('\n'); +} + export function genIndex(path, content = '', router, depth, indexKey) { const tokens = window.marked.lexer(content); const slugify = window.Docsify.slugify; @@ -224,8 +323,6 @@ export function search(query) { ), 'gi', ); - let indexTitle = -1; - let indexContent = -1; handlePostTitle = postTitle ? escapeHtml(ignoreDiacriticalMarks(postTitle)) : postTitle; @@ -233,8 +330,8 @@ export function search(query) { ? escapeHtml(ignoreDiacriticalMarks(postContent)) : postContent; - indexTitle = postTitle ? handlePostTitle.search(regEx) : -1; - indexContent = postContent ? handlePostContent.search(regEx) : -1; + const indexTitle = postTitle ? handlePostTitle.search(regEx) : -1; + let indexContent = postContent ? handlePostContent.search(regEx) : -1; if (indexTitle >= 0 || indexContent >= 0) { matchesScore += indexTitle >= 0 ? 3 : indexContent >= 0 ? 2 : 0; @@ -242,11 +339,8 @@ export function search(query) { indexContent = 0; } - let start = 0; - let end = 0; - - start = indexContent < 11 ? 0 : indexContent - 10; - end = start === 0 ? 100 : indexContent + keyword.length + 90; + const start = indexContent < 11 ? 0 : indexContent - 10; + let end = start === 0 ? 100 : indexContent + keyword.length + 90; if (handlePostContent && end > handlePostContent.length) { end = handlePostContent.length; @@ -341,10 +435,14 @@ export async function init(config, vm) { } Docsify.get(vm.router.getFile(path), false, vm.config.requestHeaders).then( - result => { + async result => { + const embeddedContent = await getEmbeddedContent(result, path, vm); + const contentToIndex = embeddedContent + ? `${result}\n${embeddedContent}` + : result; INDEXES[path] = genIndex( path, - result, + contentToIndex, vm.router, config.depth, indexKey, diff --git a/test/e2e/search.test.js b/test/e2e/search.test.js index f6b02191d7..c36c0a1812 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -401,10 +401,64 @@ console.log('Hello World'); await docsifyInit(docsifyInitConfig); await searchFieldElm.fill('filename'); expect(await resultsHeadingElm.textContent()).toContain( - '...filename _media/example.js :include :type=code :fragment=demo...', + 'filename _media/example.js :include :type=code :fragment=demo', ); }); + test('search should index embedded include content', async ({ page }) => { + const docsifyInitConfig = { + markdown: { + homepage: ` +# Include Search + +![snippet](snippet.js ':include :type=code') + `, + }, + routes: { + '/snippet.js': ` +const embeddedSearchKeyword = 'ok'; + `, + }, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await docsifyInit(docsifyInitConfig); + await searchFieldElm.fill('embeddedSearchKeyword'); + await expect(resultsHeadingElm).toHaveText('Include Search'); + }); + + test('search should index embedded include content from relative path', async ({ + page, + }) => { + const docsifyInitConfig = { + markdown: { + homepage: '# Home', + sidebar: '- [Guide Intro](guide/intro)', + }, + routes: { + '/guide/intro.md': ` +# Relative Include Search + +![snippet](./snippets/demo.js ':include :type=code') + `, + '/guide/snippets/demo.js': ` +const embeddedRelativeKeyword = 'ok'; + `, + }, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await docsifyInit(docsifyInitConfig); + await searchFieldElm.fill('embeddedRelativeKeyword'); + await expect(resultsHeadingElm).toHaveText('Relative Include Search'); + }); + test('search result should remove checkbox markdown and keep related values', async ({ page, }) => { From 593e00ce679824f408a478546b377041a683906b Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Tue, 26 May 2026 11:35:46 +0800 Subject: [PATCH 3/3] fix: format code for consistency in search.js --- src/plugins/search/search.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index 41b83acc24..eb6f4d6186 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -4,7 +4,11 @@ import { removeAtag, escapeHtml, } from '../../core/render/utils.js'; -import { getPath, getParentPath, isAbsolutePath } from '../../core/router/util.js'; +import { + getPath, + getParentPath, + isAbsolutePath, +} from '../../core/router/util.js'; import { markdownToTxt } from './markdown-to-txt.js'; import Dexie from 'dexie'; @@ -126,7 +130,9 @@ function extractFragmentContent(text, fragment, fullLine) { fragmentRegex = `.*${fragmentRegex}.*\n`; } - const pattern = new RegExp(`(?:${fragmentRegex})([\\s\\S]*?)(?:${fragmentRegex})`); + const pattern = new RegExp( + `(?:${fragmentRegex})([\\s\\S]*?)(?:${fragmentRegex})`, + ); const match = text.match(pattern); return ((match || [])[1] || '').trim(); } @@ -136,7 +142,10 @@ function collectEmbedRequests(raw = '', path, vm) { const requests = []; const maybePushEmbed = inlineToken => { - if (!inlineToken || (inlineToken.type !== 'link' && inlineToken.type !== 'image')) { + if ( + !inlineToken || + (inlineToken.type !== 'link' && inlineToken.type !== 'image') + ) { return; } @@ -203,7 +212,9 @@ async function getEmbeddedContent(raw = '', path, vm) { ); } - resolve(request.type === 'markdown' ? content : markdownToTxt(content)); + resolve( + request.type === 'markdown' ? content : markdownToTxt(content), + ); }, () => resolve(''), );