fix(insight): escape html in result titles

This commit is contained in:
ppoffice 2020-03-13 12:50:21 -04:00
parent 1346e306e1
commit e12a4da764
7 changed files with 3 additions and 519 deletions

View File

@ -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);

0
layout/search/.gitkeep Normal file
View File

View File

@ -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:;">&times;</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;

View File

@ -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:;">&times;</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;

View File

@ -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",

View File

@ -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');
});
}

View File

@ -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;
});
}