diff --git a/docs/plugins.md b/docs/plugins.md index 00da722c8..bca83191b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -65,6 +65,11 @@ By default, the hyperlink on the current page is recognized and the content is s // You can provide a regexp to match prefixes. In this case, // the matching substring will be used to identify the index pathNamespaces: /^(\/(zh-cn|ru-ru))?(\/(v1|v2))?/, + + // Show where each result comes from (default: 'none') + // 'page': the page title, e.g. "Guide" + // 'breadcrumb': the sidebar path, e.g. "Basics โบ Guide" + resultSource: 'none', }, }; diff --git a/src/plugins/search/component.js b/src/plugins/search/component.js index 59c012d5d..07e9a91b9 100644 --- a/src/plugins/search/component.js +++ b/src/plugins/search/component.js @@ -3,6 +3,126 @@ import cssText from './style.css'; import { escapeHtml } from '../../core/render/utils.js'; let NO_DATA_TEXT = ''; +let RESULT_SOURCE = 'none'; + +// Strip emoji (pictographs, flags, variation selectors, ZWJ, keycaps) from +// sidebar labels and page titles so source labels stay plain text. +function stripEmoji(text) { + return (text || '') + .replace( + /(?:[\uD83C-\uD83E][\uDC00-\uDFFF])|[\u2600-\u27BF\u2B00-\u2BFF]|\uFE0E|\uFE0F|\u200D|\u20E3/g, + '', + ) + .replace(/\s+/g, ' ') + .trim(); +} + +// User-authored links may contain malformed percent-encoding, on which +// decodeURIComponent() throws. +function safeDecode(uri) { + try { + return decodeURIComponent(uri); + } catch { + return uri; + } +} + +function findSidebarLink(url) { + const base = safeDecode((url || '').split('?')[0]); + + return Docsify.dom + .findAll('.sidebar-nav a') + .find( + a => safeDecode((a.getAttribute('href') || '').split('?')[0]) === base, + ); +} + +// Label of a sidebar list item: its own text or link text, without the text +// of the nested list of children. +function groupLabel(li) { + for (const node of li.childNodes) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) { + return node.textContent.trim(); + } + + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === 'UL') { + break; + } + + const text = node.textContent.trim(); + + if (text) { + return text; + } + } + } + + return ''; +} + +// Walk the sidebar tree from the link matching the result URL up to the +// root, collecting section labels along the way. +function getBreadcrumb(url) { + const link = findSidebarLink(url); + + if (!link) { + return null; + } + + const parts = [link.textContent.trim()]; + let li = link.closest('li'); + + while (li) { + const parentLi = li.parentElement ? li.parentElement.closest('li') : null; + + if (parentLi) { + const label = groupLabel(parentLi); + + if (label) { + parts.unshift(label); + } + } + + li = parentLi; + } + + return parts; +} + +function resultSourceHtml(post) { + if (RESULT_SOURCE === 'breadcrumb') { + const parts = getBreadcrumb(post.url); + + if (parts && parts.length) { + const crumbs = parts + .map((part, i) => { + const label = escapeHtml(stripEmoji(part)); + // The page itself (last segment) stands out from its sections. + return i === parts.length - 1 ? `${label}` : label; + }) + .join(' โบ '); + + return /* html */ `
`; + } + + // The page is not in the sidebar: fall back to its page title. + return post.page + ? /* html */ `` + : ''; + } + + if (RESULT_SOURCE === 'page') { + // Skip the label when the matched title is the page title itself. + const page = post.page && post.page !== post.title ? post.page : ''; + + return page + ? /* html */ `` + : ''; + } + + return ''; +} function tpl(vm, defaultValue = '') { const { insertAfter, insertBefore } = vm.config?.search || {}; @@ -59,6 +179,7 @@ function doSearch(value) {${post.title}
${content}
+ ${resultSourceHtml(post)} `; @@ -141,6 +262,7 @@ export function init(opts, vm) { const keywords = vm.router.parse().query.s || ''; + RESULT_SOURCE = opts.resultSource || RESULT_SOURCE; Docsify.dom.style(cssText); tpl(vm, escapeHtml(keywords)); bindEvents(); @@ -148,6 +270,7 @@ export function init(opts, vm) { } export function update(opts, vm) { + RESULT_SOURCE = opts.resultSource || RESULT_SOURCE; updatePlaceholder(opts.placeholder, vm.route.path); updateNoData(opts.noData, vm.route.path); } diff --git a/src/plugins/search/index.js b/src/plugins/search/index.js index f6ef34df0..6b1ac0b32 100644 --- a/src/plugins/search/index.js +++ b/src/plugins/search/index.js @@ -16,6 +16,7 @@ import { init as initSearch } from './search.js'; * keyBindings: string[]; * insertAfter?: string; * insertBefore?: string; + * resultSource?: 'none' | 'page' | 'breadcrumb'; * }} */ const CONFIG = { placeholder: 'Type to search', @@ -28,6 +29,7 @@ const CONFIG = { keyBindings: ['/', 'meta+k', 'ctrl+k'], insertAfter: undefined, // CSS selector insertBefore: undefined, // CSS selector + resultSource: 'none', // 'none' | 'page' | 'breadcrumb' }; const install = function (hook, vm) { @@ -45,6 +47,7 @@ const install = function (hook, vm) { CONFIG.namespace = opts.namespace || CONFIG.namespace; CONFIG.pathNamespaces = opts.pathNamespaces || CONFIG.pathNamespaces; CONFIG.keyBindings = opts.keyBindings || CONFIG.keyBindings; + CONFIG.resultSource = opts.resultSource || CONFIG.resultSource; } const isAuto = CONFIG.paths === 'auto'; diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index eb6f4d618..a30f67b74 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -232,6 +232,7 @@ export function genIndex(path, content = '', router, depth, indexKey) { const index = {}; let slug; let title = ''; + let pageTitle = ''; tokens.forEach((token, tokenIndex) => { if (token.type === 'heading' && token.depth <= depth) { @@ -244,6 +245,10 @@ export function genIndex(path, content = '', router, depth, indexKey) { title = removeAtag(title.trim()); } + if (!pageTitle && title) { + pageTitle = title; + } + index[slug] = { slug, title: title, @@ -292,6 +297,13 @@ export function genIndex(path, content = '', router, depth, indexKey) { } }); slugify.clear(); + + // Let every entry know which page it belongs to, so search results can + // show the page title next to matched section titles. + Object.values(index).forEach(item => { + item.pageTitle = pageTitle; + }); + return index; } @@ -368,11 +380,15 @@ export function search(query) { }); if (matchesScore > 0) { + const postPageTitle = post.pageTitle && post.pageTitle.trim(); const matchingPost = { title: handlePostTitle, content: postContent ? resultStr : '', url: postUrl, score: matchesScore, + page: postPageTitle + ? escapeHtml(ignoreDiacriticalMarks(postPageTitle)) + : '', }; matchingResults.push(matchingPost); diff --git a/src/plugins/search/style.css b/src/plugins/search/style.css index 163bb87b1..408d6bc69 100644 --- a/src/plugins/search/style.css +++ b/src/plugins/search/style.css @@ -148,6 +148,13 @@ font-size: var(--font-size-s); } +.search .matching-post .search-breadcrumb { + margin: 0.35em 0 0 0; + color: var(--color-mono-7); + font-size: var(--font-size-s); + text-align: right; +} + .search .results-status { margin-bottom: 0; color: var(--color-mono-6); diff --git a/test/e2e/search.test.js b/test/e2e/search.test.js index c36c0a181..0344f0a21 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -36,6 +36,110 @@ test.describe('Search Plugin Tests', () => { await expect(resultsHeadingElm).toHaveText('Test Page'); }); + test.describe('resultSource option', () => { + const markdown = { + homepage: ` + # Hello World + + This is the homepage. + `, + sidebar: ` + - Guides ๐ + - [Test Page ๐](test) + `, + }; + const routes = { + '/test.md': ` + # Test Page ๐งช + + This is a custom route. + + ## Deep Section + + Content about volcanoes. + `, + }; + + test('shows nothing by default', async ({ page }) => { + const docsifyInitConfig = { + markdown, + routes, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + const resultsPageElm = page.locator('.results-panel .search-breadcrumb'); + + await docsifyInit(docsifyInitConfig); + + await searchFieldElm.fill('volcanoes'); + await expect(resultsHeadingElm).toHaveText('Deep Section'); + await expect(resultsPageElm).toHaveCount(0); + }); + + test('page mode shows page title for section matches', async ({ page }) => { + const docsifyInitConfig = { + config: { + search: { + resultSource: 'page', + }, + }, + markdown, + routes, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + const resultsPageElm = page.locator('.results-panel .search-breadcrumb'); + + await docsifyInit(docsifyInitConfig); + + // A match inside a section shows which page it comes from, + // with emoji stripped from the page title. + await searchFieldElm.fill('volcanoes'); + await expect(resultsHeadingElm).toHaveText('Deep Section'); + await expect(resultsPageElm).toHaveText('Test Page'); + + // A match on the page title itself does not repeat the page title. + await page.click('.clear-button'); + await searchFieldElm.fill('custom route'); + await expect(resultsHeadingElm).toHaveText('Test Page ๐งช'); + await expect(resultsPageElm).toHaveCount(0); + }); + + test('breadcrumb mode shows sidebar path to the page', async ({ page }) => { + const docsifyInitConfig = { + config: { + search: { + resultSource: 'breadcrumb', + }, + }, + markdown, + routes, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + const resultsPageElm = page.locator('.results-panel .search-breadcrumb'); + + await docsifyInit(docsifyInitConfig); + + // Emoji from sidebar labels are stripped from the breadcrumb. + await searchFieldElm.fill('volcanoes'); + await expect(resultsHeadingElm).toHaveText('Deep Section'); + await expect(resultsPageElm).toHaveText('Guides โบ Test Page'); + + // A page absent from the sidebar falls back to its page title. + await page.click('.clear-button'); + await searchFieldElm.fill('homepage'); + await expect(resultsHeadingElm).toHaveText('Hello World'); + await expect(resultsPageElm).toHaveText('Hello World'); + }); + }); + test('search ignore title', async ({ page }) => { const docsifyInitConfig = { markdown: {