Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
</script>
Expand Down
123 changes: 123 additions & 0 deletions src/plugins/search/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? `<strong>${label}</strong>` : label;
})
.join(' › ');

return /* html */ `<p class="search-breadcrumb clamp-1">${crumbs}</p>`;
}

// The page is not in the sidebar: fall back to its page title.
return post.page
? /* html */ `<p class="search-breadcrumb clamp-1"><strong>${stripEmoji(post.page)}</strong></p>`
: '';
}

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 */ `<p class="search-breadcrumb clamp-1"><strong>${stripEmoji(page)}</strong></p>`
: '';
}

return '';
}

function tpl(vm, defaultValue = '') {
const { insertAfter, insertBefore } = vm.config?.search || {};
Expand Down Expand Up @@ -59,6 +179,7 @@ function doSearch(value) {
<a href="${post.url}" title="${title}">
<p class="title clamp-1">${post.title}</p>
<p class="content clamp-2">${content}</p>
${resultSourceHtml(post)}
</a>
</div>
`;
Expand Down Expand Up @@ -141,13 +262,15 @@ 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();
keywords && setTimeout(_ => doSearch(keywords), 500);
}

export function update(opts, vm) {
RESULT_SOURCE = opts.resultSource || RESULT_SOURCE;
updatePlaceholder(opts.placeholder, vm.route.path);
updateNoData(opts.noData, vm.route.path);
}
3 changes: 3 additions & 0 deletions src/plugins/search/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
Expand All @@ -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';
Expand Down
16 changes: 16 additions & 0 deletions src/plugins/search/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/plugins/search/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
104 changes: 104 additions & 0 deletions test/e2e/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down