fix(insight): escape html in result titles
This commit is contained in:
parent
1346e306e1
commit
e12a4da764
|
@ -5,7 +5,8 @@ module.exports = hexo => {
|
|||
require('hexo-component-inferno/lib/hexo/filter/locals')(hexo);
|
||||
require('./hexo/filter/stylus')(hexo);
|
||||
require('./hexo/generator/category')(hexo);
|
||||
require('./hexo/generator/insight')(hexo);
|
||||
require('hexo-component-inferno/lib/hexo/generator/assets')(hexo);
|
||||
require('hexo-component-inferno/lib/hexo/generator/insight')(hexo);
|
||||
require('hexo-component-inferno/lib/hexo/generator/categories')(hexo);
|
||||
require('hexo-component-inferno/lib/hexo/generator/tags')(hexo);
|
||||
require('hexo-component-inferno/lib/hexo/helper/cdn')(hexo);
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
const { Component, Fragment } = require('inferno');
|
||||
const { cacheComponent } = require('hexo-component-inferno/lib/util/cache');
|
||||
|
||||
class Algolia extends Component {
|
||||
render() {
|
||||
const {
|
||||
translation,
|
||||
applicationId,
|
||||
apiKey,
|
||||
indexName,
|
||||
jsUrl,
|
||||
algoliaSearchUrl,
|
||||
instantSearchUrl
|
||||
} = this.props;
|
||||
|
||||
if (!applicationId || !apiKey || !indexName) {
|
||||
return <div class="notification is-danger">
|
||||
It seems that you forget to set the <code>applicationId</code>, <code>apiKey</code>,
|
||||
or <code>indexName</code> for the Aloglia.
|
||||
Please set it in <code>_config.yml</code>.
|
||||
</div>;
|
||||
}
|
||||
|
||||
const js = `document.addEventListener('DOMContentLoaded', function () {
|
||||
loadAlgolia(${JSON.stringify({ applicationId, apiKey, indexName })}, ${JSON.stringify(translation)});
|
||||
});`;
|
||||
|
||||
return <Fragment>
|
||||
<div class="searchbox">
|
||||
<div class="searchbox-container">
|
||||
<div class="searchbox-header">
|
||||
<div class="searchbox-input-container" id="algolia-input">
|
||||
</div>
|
||||
<div class="is-flex" id="algolia-poweredby"
|
||||
style="margin:0 .5em 0 1em;align-items:center;line-height:0"></div>
|
||||
<a class="searchbox-close" href="javascript:;">×</a>
|
||||
</div>
|
||||
<div class="searchbox-body"></div>
|
||||
<div class="searchbox-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src={algoliaSearchUrl} crossorigin="anonymous" defer={true}></script>
|
||||
<script src={instantSearchUrl} crossorigin="anonymous" defer={true}></script>
|
||||
<script src={jsUrl} defer={true}></script>
|
||||
<script dangerouslySetInnerHTML={{ __html: js }}></script>
|
||||
</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Algolia.Cacheable = cacheComponent(Algolia, 'search.algolia', props => {
|
||||
const { config, helper } = props;
|
||||
const { algolia } = config;
|
||||
|
||||
return {
|
||||
translation: {
|
||||
hint: helper.__('search.hint'),
|
||||
no_result: helper.__('search.no_result'),
|
||||
untitled: helper.__('search.untitled'),
|
||||
empty_preview: helper.__('search.empty_preview')
|
||||
},
|
||||
applicationId: algolia ? algolia.applicationID : null,
|
||||
apiKey: algolia ? algolia.apiKey : null,
|
||||
indexName: algolia ? algolia.indexName : null,
|
||||
algoliaSearchUrl: helper.cdn('algoliasearch', '4.0.3', 'dist/algoliasearch-lite.umd.js'),
|
||||
instantSearchUrl: helper.cdn('instantsearch.js', '4.3.1', 'dist/instantsearch.production.min.js'),
|
||||
jsUrl: helper.url_for('/js/algolia.js')
|
||||
};
|
||||
});
|
||||
|
||||
module.exports = Algolia;
|
|
@ -1,47 +0,0 @@
|
|||
const { Component, Fragment } = require('inferno');
|
||||
const { cacheComponent } = require('hexo-component-inferno/lib/util/cache');
|
||||
|
||||
class Insight extends Component {
|
||||
render() {
|
||||
const { translation, contentUrl, jsUrl } = this.props;
|
||||
|
||||
const js = `document.addEventListener('DOMContentLoaded', function () {
|
||||
loadInsight(${JSON.stringify({ contentUrl })}, ${JSON.stringify(translation)});
|
||||
});`;
|
||||
|
||||
return <Fragment>
|
||||
<div class="searchbox">
|
||||
<div class="searchbox-container">
|
||||
<div class="searchbox-header">
|
||||
<div class="searchbox-input-container">
|
||||
<input type="text" class="searchbox-input" placeholder={translation.hint}/>
|
||||
</div>
|
||||
<a class="searchbox-close" href="javascript:;">×</a>
|
||||
</div>
|
||||
<div class="searchbox-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src={jsUrl} defer={true}></script>
|
||||
<script dangerouslySetInnerHTML={{ __html: js }}></script>
|
||||
</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Insight.Cacheable = cacheComponent(Insight, 'search.insight', props => {
|
||||
const { helper } = props;
|
||||
|
||||
return {
|
||||
translation: {
|
||||
hint: helper.__('search.hint'),
|
||||
untitled: helper.__('search.untitled'),
|
||||
posts: helper._p('common.post', Infinity),
|
||||
pages: helper._p('common.page', Infinity),
|
||||
categories: helper._p('common.category', Infinity),
|
||||
tags: helper._p('common.tag', Infinity)
|
||||
},
|
||||
contentUrl: helper.url_for('/content.json'),
|
||||
jsUrl: helper.url_for('/js/insight.js')
|
||||
};
|
||||
});
|
||||
|
||||
module.exports = Insight;
|
|
@ -22,7 +22,7 @@
|
|||
"bulma-stylus": "0.8.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"hexo": "^4.2.0",
|
||||
"hexo-component-inferno": "^0.1.3",
|
||||
"hexo-component-inferno": "^0.2.0",
|
||||
"hexo-log": "^1.0.0",
|
||||
"hexo-pagination": "^1.0.0",
|
||||
"hexo-renderer-inferno": "^0.1.3",
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
/* global instantsearch, algoliasearch */
|
||||
function loadAlgolia(config, translation) { // eslint-disable-line no-unused-vars
|
||||
const search = instantsearch({
|
||||
indexName: config.indexName,
|
||||
searchClient: algoliasearch(config.applicationId, config.apiKey)
|
||||
});
|
||||
|
||||
search.addWidgets([
|
||||
instantsearch.widgets.configure({
|
||||
attributesToSnippet: ['excerpt']
|
||||
})
|
||||
]);
|
||||
|
||||
search.addWidget(instantsearch.widgets.searchBox({
|
||||
container: '#algolia-input',
|
||||
placeholder: translation.hint,
|
||||
showReset: false,
|
||||
showSubmit: false,
|
||||
showLoadingIndicator: false,
|
||||
cssClasses: {
|
||||
root: 'searchbox-input-container',
|
||||
form: 'searchbox-input-container',
|
||||
input: 'searchbox-input'
|
||||
}
|
||||
}));
|
||||
|
||||
search.addWidget(instantsearch.widgets.poweredBy({
|
||||
container: '#algolia-poweredby'
|
||||
}));
|
||||
|
||||
search.addWidget(instantsearch.widgets.hits({
|
||||
container: '.searchbox-body',
|
||||
escapeHTML: false,
|
||||
cssClasses: {
|
||||
root: 'searchbox-result-container',
|
||||
emptyRoot: ['searchbox-result-item', 'disabled']
|
||||
},
|
||||
templates: {
|
||||
empty: function(results) {
|
||||
return translation.no_result + ': ' + results.query;
|
||||
},
|
||||
item: function(hit) {
|
||||
const title = instantsearch.highlight({ attribute: 'title', hit });
|
||||
let excerpt = instantsearch.highlight({ attribute: 'excerpt', hit });
|
||||
excerpt = excerpt.replace(new RegExp('<em>', 'ig'), '[algolia-highlight]')
|
||||
.replace(new RegExp('</em>', 'ig'), '[/algolia-highlight]')
|
||||
.replace(/(<([^>]+)>)/ig, '')
|
||||
.replace(/(\[algolia-highlight\])/ig, '<em>')
|
||||
.replace(/(\[\/algolia-highlight\])/ig, '</em>');
|
||||
return `<section class="searchbox-result-section">
|
||||
<a class="searchbox-result-item" href="${hit.permalink}">
|
||||
<span class="searchbox-result-content">
|
||||
<span class="searchbox-result-title">${title ? title : translation.untitled}</span>
|
||||
<span class="searchbox-result-preview">${excerpt ? excerpt : translation.empty_preview}</span>
|
||||
</span>
|
||||
</a>
|
||||
</section>`;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
search.addWidget(instantsearch.widgets.pagination({
|
||||
container: '.searchbox-footer',
|
||||
cssClasses: {
|
||||
list: 'searchbox-pagination',
|
||||
item: 'searchbox-pagination-item',
|
||||
link: 'searchbox-pagination-link',
|
||||
selectedItem: 'active',
|
||||
disabledItem: 'disabled'
|
||||
}
|
||||
}));
|
||||
|
||||
search.start();
|
||||
|
||||
if (location.hash.trim() === '#algolia-search') {
|
||||
$('.searchbox').addClass('show');
|
||||
}
|
||||
|
||||
$(document).on('click', '.navbar-main .search', () => {
|
||||
$('.searchbox').toggleClass('show');
|
||||
$('.searchbox-input').focus();
|
||||
}).on('click', '.searchbox .searchbox-mask', () => {
|
||||
$('.searchbox').removeClass('show');
|
||||
}).on('click', '.searchbox-close', () => {
|
||||
$('.searchbox').removeClass('show');
|
||||
});
|
||||
}
|
|
@ -1,313 +0,0 @@
|
|||
/**
|
||||
* Insight search plugin
|
||||
* @author PPOffice { @link https://github.com/ppoffice }
|
||||
*/
|
||||
function loadInsight(config, translation) { // eslint-disable-line no-unused-vars
|
||||
const $main = $('.searchbox');
|
||||
const $input = $main.find('.searchbox-input');
|
||||
const $container = $main.find('.searchbox-body');
|
||||
|
||||
function section(title) {
|
||||
return $('<section>').addClass('searchbox-result-section').append($('<header>').text(title));
|
||||
}
|
||||
|
||||
function merge(ranges) {
|
||||
let last;
|
||||
const result = [];
|
||||
|
||||
ranges.forEach(r => {
|
||||
if (!last || r[0] > last[1]) {
|
||||
result.push(last = r);
|
||||
} else if (r[1] > last[1]) {
|
||||
last[1] = r[1];
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findAndHighlight(text, matches, maxlen) {
|
||||
if (!Array.isArray(matches) || !matches.length || !text) {
|
||||
return maxlen ? text.slice(0, maxlen) : text;
|
||||
}
|
||||
const testText = text.toLowerCase();
|
||||
const indices = matches.map(match => {
|
||||
const index = testText.indexOf(match.toLowerCase());
|
||||
if (!match || index === -1) {
|
||||
return null;
|
||||
}
|
||||
return [index, index + match.length];
|
||||
}).filter(match => {
|
||||
return match !== null;
|
||||
}).sort((a, b) => {
|
||||
return a[0] - b[0] || a[1] - b[1];
|
||||
});
|
||||
|
||||
if (!indices.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = ''; let last = 0;
|
||||
const ranges = merge(indices);
|
||||
const sumRange = [ranges[0][0], ranges[ranges.length - 1][1]];
|
||||
if (maxlen && maxlen < sumRange[1]) {
|
||||
last = sumRange[0];
|
||||
}
|
||||
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
result += text.slice(last, Math.min(range[0], sumRange[0] + maxlen));
|
||||
if (maxlen && range[0] >= sumRange[0] + maxlen) {
|
||||
break;
|
||||
}
|
||||
result += '<em>' + text.slice(range[0], range[1]) + '</em>';
|
||||
last = range[1];
|
||||
if (i === ranges.length - 1) {
|
||||
if (maxlen) {
|
||||
result += text.slice(range[1], Math.min(text.length, sumRange[0] + maxlen + 1));
|
||||
} else {
|
||||
result += text.slice(range[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function searchItem(icon, title, slug, preview, url) {
|
||||
title = title != null && title !== '' ? title : translation.untitled;
|
||||
|
||||
return `<a class="searchbox-result-item" href="${url}">
|
||||
<span class="searchbox-result-icon">
|
||||
<i class="fa fa-${icon}" />
|
||||
</span>
|
||||
<span class="searchbox-result-content">
|
||||
<span class="searchbox-result-title">
|
||||
${title}
|
||||
${slug ? '<span class="searchbox-result-title-secondary">(' + slug + ')</span>' : ''}
|
||||
</span>
|
||||
${preview ? '<span class="searchbox-result-preview">' + preview + '</span>' : ''}
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function sectionFactory(keywords, type, array) {
|
||||
let $searchItems;
|
||||
if (array.length === 0) return null;
|
||||
const sectionTitle = translation[type.toLowerCase()];
|
||||
switch (type) {
|
||||
case 'POSTS':
|
||||
case 'PAGES':
|
||||
$searchItems = array.map(item => {
|
||||
const title = findAndHighlight(item.title, keywords);
|
||||
const text = findAndHighlight(item.text, keywords, 100);
|
||||
return searchItem('file', title, null, text, item.link);
|
||||
});
|
||||
break;
|
||||
case 'CATEGORIES':
|
||||
case 'TAGS':
|
||||
$searchItems = array.map(item => {
|
||||
const name = findAndHighlight(item.name, keywords);
|
||||
const slug = findAndHighlight(item.slug, keywords);
|
||||
return searchItem(type === 'CATEGORIES' ? 'folder' : 'tag', name, slug, null, item.link);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return section(sectionTitle).append($searchItems);
|
||||
}
|
||||
|
||||
function parseKeywords(keywords) {
|
||||
return keywords.split(' ').filter(keyword => {
|
||||
return !!keyword;
|
||||
}).map(keyword => {
|
||||
return keyword.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge if a given post/page/category/tag contains all of the keywords.
|
||||
* @param Object obj Object to be weighted
|
||||
* @param Array<String> fields Object's fields to find matches
|
||||
*/
|
||||
function filter(keywords, obj, fields) {
|
||||
const keywordArray = parseKeywords(keywords);
|
||||
const containKeywords = keywordArray.filter(keyword => {
|
||||
const containFields = fields.filter(field => {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, field)) {
|
||||
return false;
|
||||
}
|
||||
if (obj[field].toLowerCase().indexOf(keyword) > -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (containFields.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return containKeywords.length === keywordArray.length;
|
||||
}
|
||||
|
||||
function filterFactory(keywords) {
|
||||
return {
|
||||
post: function(obj) {
|
||||
return filter(keywords, obj, ['title', 'text']);
|
||||
},
|
||||
page: function(obj) {
|
||||
return filter(keywords, obj, ['title', 'text']);
|
||||
},
|
||||
category: function(obj) {
|
||||
return filter(keywords, obj, ['name', 'slug']);
|
||||
},
|
||||
tag: function(obj) {
|
||||
return filter(keywords, obj, ['name', 'slug']);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the weight of a matched post/page/category/tag.
|
||||
* @param Object obj Object to be weighted
|
||||
* @param Array<String> fields Object's fields to find matches
|
||||
* @param Array<Integer> weights Weight of every field
|
||||
*/
|
||||
function weight(keywords, obj, fields, weights) {
|
||||
let value = 0;
|
||||
parseKeywords(keywords).forEach(keyword => {
|
||||
const pattern = new RegExp(keyword, 'img'); // Global, Multi-line, Case-insensitive
|
||||
fields.forEach((field, index) => {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, field)) {
|
||||
const matches = obj[field].match(pattern);
|
||||
value += matches ? matches.length * weights[index] : 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function weightFactory(keywords) {
|
||||
return {
|
||||
post: function(obj) {
|
||||
return weight(keywords, obj, ['title', 'text'], [3, 1]);
|
||||
},
|
||||
page: function(obj) {
|
||||
return weight(keywords, obj, ['title', 'text'], [3, 1]);
|
||||
},
|
||||
category: function(obj) {
|
||||
return weight(keywords, obj, ['name', 'slug'], [1, 1]);
|
||||
},
|
||||
tag: function(obj) {
|
||||
return weight(keywords, obj, ['name', 'slug'], [1, 1]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function search(json, keywords) {
|
||||
const weights = weightFactory(keywords);
|
||||
const filters = filterFactory(keywords);
|
||||
const posts = json.posts;
|
||||
const pages = json.pages;
|
||||
const tags = json.tags;
|
||||
const categories = json.categories;
|
||||
return {
|
||||
posts: posts.filter(filters.post).sort((a, b) => { return weights.post(b) - weights.post(a); }).slice(0, 5),
|
||||
pages: pages.filter(filters.page).sort((a, b) => { return weights.page(b) - weights.page(a); }).slice(0, 5),
|
||||
categories: categories.filter(filters.category).sort((a, b) => { return weights.category(b) - weights.category(a); }).slice(0, 5),
|
||||
tags: tags.filter(filters.tag).sort((a, b) => { return weights.tag(b) - weights.tag(a); }).slice(0, 5)
|
||||
};
|
||||
}
|
||||
|
||||
function searchResultToDOM(keywords, searchResult) {
|
||||
$container.empty();
|
||||
for (const key in searchResult) {
|
||||
$container.append(sectionFactory(parseKeywords(keywords),
|
||||
key.toUpperCase(), searchResult[key]));
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTo($item) {
|
||||
if ($item.length === 0) return;
|
||||
const wrapperHeight = $container[0].clientHeight;
|
||||
const itemTop = $item.position().top - $container.scrollTop();
|
||||
const itemBottom = $item[0].clientHeight + $item.position().top;
|
||||
if (itemBottom > wrapperHeight + $container.scrollTop()) {
|
||||
$container.scrollTop(itemBottom - $container[0].clientHeight);
|
||||
}
|
||||
if (itemTop < 0) {
|
||||
$container.scrollTop($item.position().top);
|
||||
}
|
||||
}
|
||||
|
||||
function selectItemByDiff(value) {
|
||||
const $items = $.makeArray($container.find('.searchbox-result-item'));
|
||||
let prevPosition = -1;
|
||||
$items.forEach((item, index) => {
|
||||
if ($(item).hasClass('active')) {
|
||||
prevPosition = index;
|
||||
|
||||
}
|
||||
});
|
||||
const nextPosition = ($items.length + prevPosition + value) % $items.length;
|
||||
$($items[prevPosition]).removeClass('active');
|
||||
$($items[nextPosition]).addClass('active');
|
||||
scrollTo($($items[nextPosition]));
|
||||
}
|
||||
|
||||
function gotoLink($item) {
|
||||
if ($item && $item.length) {
|
||||
location.href = $item.attr('href');
|
||||
}
|
||||
}
|
||||
|
||||
$.getJSON(config.contentUrl, json => {
|
||||
if (location.hash.trim() === '#insight-search') {
|
||||
$main.addClass('show');
|
||||
}
|
||||
$input.on('input', function() {
|
||||
const keywords = $(this).val();
|
||||
searchResultToDOM(keywords, search(json, keywords));
|
||||
});
|
||||
$input.trigger('input');
|
||||
});
|
||||
|
||||
let touch = false;
|
||||
$(document).on('click focus', '.navbar-main .search', () => {
|
||||
$main.addClass('show');
|
||||
$main.find('.searchbox-input').focus();
|
||||
}).on('click touchend', '.searchbox-result-item', function(e) {
|
||||
if (e.type !== 'click' && !touch) {
|
||||
return;
|
||||
}
|
||||
gotoLink($(this));
|
||||
touch = false;
|
||||
}).on('click touchend', '.searchbox-close', e => {
|
||||
if (e.type !== 'click' && !touch) {
|
||||
return;
|
||||
}
|
||||
$('.navbar-main').css('pointer-events', 'none');
|
||||
setTimeout(() => {
|
||||
$('.navbar-main').css('pointer-events', 'auto');
|
||||
}, 400);
|
||||
$main.removeClass('show');
|
||||
touch = false;
|
||||
}).on('keydown', e => {
|
||||
if (!$main.hasClass('show')) return;
|
||||
switch (e.keyCode) {
|
||||
case 27: // ESC
|
||||
$main.removeClass('show'); break;
|
||||
case 38: // UP
|
||||
selectItemByDiff(-1); break;
|
||||
case 40: // DOWN
|
||||
selectItemByDiff(1); break;
|
||||
case 13: // ENTER
|
||||
gotoLink($container.find('.searchbox-result-item.active').eq(0)); break;
|
||||
}
|
||||
}).on('touchstart', e => {
|
||||
touch = true;
|
||||
}).on('touchmove', e => {
|
||||
touch = false;
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue