feat: improve indexing and embedded search (#2734)

This commit is contained in:
Luffy
2026-06-03 10:28:37 +08:00
committed by GitHub
parent 4c193802e5
commit ab96d74a8d
2 changed files with 333 additions and 19 deletions
+154 -18
View File
@@ -4,6 +4,11 @@ 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';
@@ -16,10 +21,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,
@@ -96,6 +120,111 @@ 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;
@@ -205,8 +334,6 @@ export function search(query) {
),
'gi',
);
let indexTitle = -1;
let indexContent = -1;
handlePostTitle = postTitle
? escapeHtml(ignoreDiacriticalMarks(postTitle))
: postTitle;
@@ -214,8 +341,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;
@@ -223,11 +350,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;
@@ -306,26 +430,38 @@ 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 => {
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,
);
if (len === ++count) {
await saveData(config.maxAge, expireKey);
}
return markComplete();
},
() => {
return markComplete();
},
);
});
+179 -1
View File
@@ -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'],
@@ -277,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,
}) => {