/* global CONFIG */
document.addEventListener('DOMContentLoaded', () => {
if (!CONFIG.path) {
// Search DB path
console.warn('`hexo-generator-searchdb` plugin is not installed!');
return;
}
// Popup Window
let isfetched = false;
let datas;
const input = document.querySelector('.search-input');
const getIndexByWord = (words, text, caseSensitive = false) => {
const index = [];
const included = new Set();
words.forEach(word => {
if (CONFIG.localsearch.unescape) {
const div = document.createElement('div');
div.innerText = word;
word = div.innerHTML;
}
const wordLen = word.length;
if (wordLen === 0) return;
let startPosition = 0;
let position = -1;
if (!caseSensitive) {
text = text.toLowerCase();
word = word.toLowerCase();
}
while ((position = text.indexOf(word, startPosition)) > -1) {
index.push({ position, word });
included.add(word);
startPosition = position + wordLen;
}
});
// Sort index by position of keyword
index.sort((left, right) => {
if (left.position !== right.position) {
return left.position - right.position;
}
return right.word.length - left.word.length;
});
return [index, included];
};
// Merge hits into slices
const mergeIntoSlice = (start, end, index) => {
let item = index[0];
let { position, word } = item;
const hits = [];
const count = new Set();
while (position + word.length <= end && index.length !== 0) {
count.add(word);
hits.push({
position,
length: word.length
});
const wordEnd = position + word.length;
// Move to next position of hit
index.shift();
while (index.length !== 0) {
item = index[0];
position = item.position;
word = item.word;
if (wordEnd > position) {
index.shift();
} else {
break;
}
}
}
return {
hits,
start,
end,
count: count.size
};
};
// Highlight title and content
const highlightKeyword = (val, slice) => {
let result = '';
let index = slice.start;
for (const { position, length } of slice.hits) {
result += val.substring(index, position);
index = position + length;
result += `${val.substr(position, length)}`;
}
result += val.substring(index, slice.end);
return result;
};
const getResultItems = keywords => {
const resultItems = [];
datas.forEach(({ title, content, url }) => {
// The number of different keywords included in the article.
const [indexOfTitle, keysOfTitle] = getIndexByWord(keywords, title);
const [indexOfContent, keysOfContent] = getIndexByWord(keywords, content);
const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size;
// Show search results
const hitCount = indexOfTitle.length + indexOfContent.length;
if (hitCount === 0) return;
const slicesOfTitle = [];
if (indexOfTitle.length !== 0) {
slicesOfTitle.push(mergeIntoSlice(0, title.length, indexOfTitle));
}
let slicesOfContent = [];
while (indexOfContent.length !== 0) {
const item = indexOfContent[0];
const { position } = item;
// Cut out 100 characters. The maxlength of .search-input is 80.
const start = Math.max(0, position - 20);
const end = Math.min(content.length, position + 80);
slicesOfContent.push(mergeIntoSlice(start, end, indexOfContent));
}
// Sort slices in content by included keywords' count and hits' count
slicesOfContent.sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
} else if (left.hits.length !== right.hits.length) {
return right.hits.length - left.hits.length;
}
return left.start - right.start;
});
// Select top N slices in content
const upperBound = parseInt(CONFIG.localsearch.top_n_per_article, 10);
if (upperBound >= 0) {
slicesOfContent = slicesOfContent.slice(0, upperBound);
}
let resultItem = '';
url = new URL(url, location.origin);
url.searchParams.append('highlight', keywords.join(' '));
if (slicesOfTitle.length !== 0) {
resultItem += `
${highlightKeyword(title, slicesOfTitle[0])}`;
} else {
resultItem += `${title}`;
}
slicesOfContent.forEach(slice => {
resultItem += `${highlightKeyword(content, slice)}...
`;
});
resultItem += '';
resultItems.push({
item: resultItem,
id : resultItems.length,
hitCount,
includedCount
});
});
return resultItems;
};
const inputEventFunction = () => {
if (!isfetched) return;
const searchText = input.value.trim().toLowerCase();
const keywords = searchText.split(/[-\s]+/);
const container = document.querySelector('.search-result-container');
let resultItems = [];
if (searchText.length > 0) {
// Perform local searching
resultItems = getResultItems(keywords);
}
if (keywords.length === 1 && keywords[0] === '') {
container.classList.add('no-result');
container.innerHTML = '
';
} else if (resultItems.length === 0) {
container.classList.add('no-result');
container.innerHTML = '
';
} else {
resultItems.sort((left, right) => {
if (left.includedCount !== right.includedCount) {
return right.includedCount - left.includedCount;
} else if (left.hitCount !== right.hitCount) {
return right.hitCount - left.hitCount;
}
return right.id - left.id;
});
const stats = CONFIG.i18n.hits.replace(/\$\{hits}/, resultItems.length);
container.classList.remove('no-result');
container.innerHTML = `${stats}
${resultItems.map(result => result.item).join('')}
`;
window.pjax && window.pjax.refresh(container);
}
};
const fetchData = () => {
const isXml = !CONFIG.path.endsWith('json');
fetch(CONFIG.path)
.then(response => response.text())
.then(res => {
// Get the contents from search data
isfetched = true;
datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => {
return {
title : element.querySelector('title').textContent,
content: element.querySelector('content').textContent,
url : element.querySelector('url').textContent
};
}) : JSON.parse(res);
// Only match articles with non-empty titles
datas = datas.filter(data => data.title).map(data => {
data.title = data.title.trim();
data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '';
data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
return data;
});
// Remove loading animation
inputEventFunction();
});
};
// Highlight by wrapping node in mark elements with the given class name
const highlightText = (node, slice, className) => {
const val = node.nodeValue;
let index = slice.start;
const children = [];
for (const { position, length } of slice.hits) {
const text = document.createTextNode(val.substring(index, position));
index = position + length;
const mark = document.createElement('mark');
mark.className = className;
mark.appendChild(document.createTextNode(val.substr(position, length)));
children.push(text, mark);
}
node.nodeValue = val.substring(index, slice.end);
children.forEach(element => {
node.parentNode.insertBefore(element, node);
});
};
// Highlight the search words provided in the url in the text
const highlightSearchWords = () => {
const params = new URL(location.href).searchParams.get('highlight');
const keywords = params ? params.split(' ') : [];
const body = document.querySelector('.post-body');
if (!keywords.length || !body) return;
const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
const allNodes = [];
while (walk.nextNode()) {
if (!walk.currentNode.parentNode.matches('button, select, textarea')) allNodes.push(walk.currentNode);
}
allNodes.forEach(node => {
const [indexOfNode] = getIndexByWord(keywords, node.nodeValue);
if (!indexOfNode.length) return;
const slice = mergeIntoSlice(0, node.nodeValue.length, indexOfNode);
highlightText(node, slice, 'search-keyword');
});
};
highlightSearchWords();
if (CONFIG.localsearch.preload) {
fetchData();
}
if (CONFIG.localsearch.trigger === 'auto') {
input.addEventListener('input', inputEventFunction);
} else {
document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
input.addEventListener('keypress', event => {
if (event.key === 'Enter') {
inputEventFunction();
}
});
}
// Handle and trigger popup window
document.querySelectorAll('.popup-trigger').forEach(element => {
element.addEventListener('click', () => {
document.body.classList.add('search-active');
// Wait for search-popup animation to complete
setTimeout(() => input.focus(), 500);
if (!isfetched) fetchData();
});
});
// Monitor main search box
const onPopupClose = () => {
document.body.classList.remove('search-active');
};
document.querySelector('.search-pop-overlay').addEventListener('click', event => {
if (event.target === document.querySelector('.search-pop-overlay')) {
onPopupClose();
}
});
document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose);
document.addEventListener('pjax:success', () => {
highlightSearchWords();
onPopupClose();
});
window.addEventListener('keyup', event => {
if (event.key === 'Escape') {
onPopupClose();
}
});
});