From 15157fc8f52753d108cfdf12936735caa63034f8 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Wed, 22 Jul 2026 19:18:39 +0200 Subject: [PATCH 1/2] feat(search): resultSource option shows where results come from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search results for matches inside a section only showed the section heading, with no hint of which page it belongs to. Index entries now carry the title of their page (first heading) and a new search config option, resultSource, controls what renders below the excerpt: - 'none' (default): current behavior, nothing extra - 'page': the page title, e.g. "Guide", omitted when the matched title is the page title itself - 'breadcrumb': the sidebar path to the page, e.g. "Basics › Guide", falling back to the page title for pages not present in the sidebar Emoji in sidebar labels and page titles are stripped so the source line stays plain text. --- docs/plugins.md | 5 ++ src/plugins/search/component.js | 115 ++++++++++++++++++++++++++++++++ src/plugins/search/index.js | 3 + src/plugins/search/search.js | 16 +++++ src/plugins/search/style.css | 7 ++ test/e2e/search.test.js | 98 +++++++++++++++++++++++++++ 6 files changed, 244 insertions(+) diff --git a/docs/plugins.md b/docs/plugins.md index 00da722c81..bca83191b4 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 59c012d5d6..e924ea1111 100644 --- a/src/plugins/search/component.js +++ b/src/plugins/search/component.js @@ -3,6 +3,118 @@ 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(); +} + +function findSidebarLink(url) { + const base = decodeURIComponent((url || '').split('?')[0]); + + return Docsify.dom + .findAll('.sidebar-nav a') + .find( + a => + decodeURIComponent((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 */ `

${crumbs}

`; + } + + // The page is not in the sidebar: fall back to its page title. + return post.page + ? /* html */ `

${stripEmoji(post.page)}

` + : ''; + } + + 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 */ `

${stripEmoji(page)}

` + : ''; + } + + return ''; +} function tpl(vm, defaultValue = '') { const { insertAfter, insertBefore } = vm.config?.search || {}; @@ -59,6 +171,7 @@ function doSearch(value) {

${post.title}

${content}

+ ${resultSourceHtml(post)}
`; @@ -141,6 +254,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 +262,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 f6ef34df01..3d66f2107b 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 eb6f4d6186..a30f67b74f 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 163bb87b1f..408d6bc690 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 c36c0a1812..ae73df69e2 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -36,6 +36,104 @@ 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'); + }); + }); + test('search ignore title', async ({ page }) => { const docsifyInitConfig = { markdown: { From 856f614354b889cef7b762310e1da504738c98ba Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Wed, 22 Jul 2026 21:07:14 +0200 Subject: [PATCH 2/2] fix(search): address review feedback - mark resultSource as optional in the CONFIG JSDoc - decode URLs via a safe helper so malformed percent-encoding in user-authored sidebar links cannot break result rendering - cover the breadcrumb fallback (page absent from the sidebar) in e2e --- src/plugins/search/component.js | 16 ++++++++++++---- src/plugins/search/index.js | 2 +- test/e2e/search.test.js | 6 ++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/plugins/search/component.js b/src/plugins/search/component.js index e924ea1111..07e9a91b95 100644 --- a/src/plugins/search/component.js +++ b/src/plugins/search/component.js @@ -17,15 +17,23 @@ function stripEmoji(text) { .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 = decodeURIComponent((url || '').split('?')[0]); + const base = safeDecode((url || '').split('?')[0]); return Docsify.dom .findAll('.sidebar-nav a') .find( - a => - decodeURIComponent((a.getAttribute('href') || '').split('?')[0]) === - base, + a => safeDecode((a.getAttribute('href') || '').split('?')[0]) === base, ); } diff --git a/src/plugins/search/index.js b/src/plugins/search/index.js index 3d66f2107b..6b1ac0b325 100644 --- a/src/plugins/search/index.js +++ b/src/plugins/search/index.js @@ -16,7 +16,7 @@ import { init as initSearch } from './search.js'; * keyBindings: string[]; * insertAfter?: string; * insertBefore?: string; - * resultSource: 'none' | 'page' | 'breadcrumb'; + * resultSource?: 'none' | 'page' | 'breadcrumb'; * }} */ const CONFIG = { placeholder: 'Type to search', diff --git a/test/e2e/search.test.js b/test/e2e/search.test.js index ae73df69e2..0344f0a219 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -131,6 +131,12 @@ test.describe('Search Plugin Tests', () => { 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'); }); });