🌐 Added ⬅️RTL to sodo-search & improved tests (#21152)

no ref

- added dir prop, calculated by i18next from language (using the dir
function)
- tweaked a few styles to use me/ms/pe/ps instead of mr/ml/pr/pl
- added updated test that checks that stemming works in English, and added tests for partial and full-word searching with RTL content.
This commit is contained in:
Cathy Sarisky 2024-10-01 14:04:54 -04:00 committed by GitHub
parent 300eba49ca
commit 6e599ef541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 191 additions and 21 deletions

View File

@ -9,20 +9,23 @@ export default class App extends React.Component {
constructor(props) {
super(props);
const searchIndex = new SearchIndex({
adminUrl: props.adminUrl,
apiKey: props.apiKey
});
const i18nLanguage = this.props.locale || 'en';
const i18n = i18nLib(i18nLanguage, 'search');
const dir = i18n.dir() || 'ltr';
const searchIndex = new SearchIndex({
adminUrl: props.adminUrl,
apiKey: props.apiKey,
dir: dir
});
this.state = {
searchIndex,
showPopup: false,
indexStarted: false,
indexComplete: false,
t: i18n.t
t: i18n.t,
dir: dir
};
this.inputRef = React.createRef();
@ -169,7 +172,8 @@ export default class App extends React.Component {
});
}
},
t: this.state.t
t: this.state.t,
dir: this.state.dir
}}>
<PopupModal />
</AppContext.Provider>

View File

@ -14,7 +14,8 @@ const AppContext = React.createContext({
searchIndex: null,
indexComplete: false,
searchValue: '',
t: () => {}
t: () => {},
dir: 'ltr'
});
export default AppContext;

View File

@ -17,6 +17,8 @@ export default class Frame extends Component {
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
// set the iframeHtml dir attribute
this.iframeHtml.setAttribute('dir', this.props.searchdir);
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();

View File

@ -100,7 +100,7 @@ function SearchBox() {
return (
<div className={className} ref={containerRef}>
<div className='flex items-center justify-center w-4 h-4 mr-3'>
<div className='flex items-center justify-center w-4 h-4 me-3'>
<SearchClearIcon />
</div>
<input
@ -116,7 +116,7 @@ function SearchBox() {
e.preventDefault();
}
}}
className='grow -my-5 py-5 -ml-3 pl-3 text-[1.65rem] focus-visible:outline-none placeholder:text-gray-400 outline-none truncate'
className='grow -my-5 py-5 -ms-3 ps-3 text-[1.65rem] focus-visible:outline-none placeholder:text-gray-400 outline-none truncate'
placeholder={t('Search posts, tags and authors')}
/>
<Loading />
@ -158,7 +158,7 @@ function CancelButton() {
return (
<button
className='ml-3 text-sm text-neutral-500 sm:hidden' alt='Cancel'
className='ms-3 text-sm text-neutral-500 sm:hidden' alt='Cancel'
onClick={() => {
dispatch('update', {
showPopup: false
@ -188,7 +188,7 @@ function TagListItem({tag, selectedResult, setSelectedResult}) {
setSelectedResult(id);
}}
>
<p className='mr-2 text-sm font-bold text-neutral-400'>#</p>
<p className='me-2 text-sm font-bold text-neutral-400'>#</p>
<h2 className='text-[1.65rem] font-medium leading-tight text-neutral-900 truncate'>{name}</h2>
</div>
);
@ -433,11 +433,11 @@ function AuthorAvatar({name, avatar}) {
const Character = name.charAt(0);
if (Avatar) {
return (
<img className='rounded-full bg-neutral-300 w-7 h-7 mr-2 object-cover' src={avatar} alt={name}/>
<img className='rounded-full bg-neutral-300 w-7 h-7 me-2 object-cover' src={avatar} alt={name}/>
);
}
return (
<div className='rounded-full bg-neutral-200 w-7 h-7 mr-2 flex items-center justify-center font-bold'><span className="text-neutral-400">{Character}</span></div>
<div className='rounded-full bg-neutral-200 w-7 h-7 me-2 flex items-center justify-center font-bold'><span className="text-neutral-400">{Character}</span></div>
);
}
@ -672,7 +672,7 @@ export default class PopupModal extends React.Component {
return (
<div style={Styles.modalContainer} className='gh-root-frame'>
<Frame style={frameStyle} title='portal-popup' head={this.renderFrameStyles()}>
<Frame style={frameStyle} title='portal-popup' head={this.renderFrameStyles()} searchdir={this.context.dir}>
<div
onClick = {e => this.handlePopupClose(e)}
className='absolute top-0 bottom-0 left-0 right-0 block backdrop-blur-[2px] animate-fadein z-0 bg-gradient-to-br from-[rgba(0,0,0,0.2)] to-[rgba(0,0,0,0.1)]' />

View File

@ -2,15 +2,17 @@ import Flexsearch from 'flexsearch';
import GhostContentAPI from '@tryghost/content-api';
export default class SearchIndex {
constructor({adminUrl, apiKey}) {
constructor({adminUrl, apiKey, dir}) {
this.api = new GhostContentAPI({
url: adminUrl,
key: apiKey,
version: 'v5.0'
});
const rtl = (dir === 'rtl');
const tokenize = (dir === 'rtl') ? 'reverse' : 'forward';
this.postsIndex = new Flexsearch.Document({
tokenize: 'forward',
tokenize: tokenize,
rtl: rtl,
document: {
id: 'id',
index: ['title', 'excerpt'],
@ -19,7 +21,8 @@ export default class SearchIndex {
...this.#getEncodeOptions()
});
this.authorsIndex = new Flexsearch.Document({
tokenize: 'forward',
tokenize: tokenize,
rtl: rtl,
document: {
id: 'id',
index: ['name'],
@ -28,7 +31,8 @@ export default class SearchIndex {
...this.#getEncodeOptions()
});
this.tagsIndex = new Flexsearch.Document({
tokenize: 'forward',
tokenize: tokenize,
rtl: rtl,
document: {
id: 'id',
index: ['name'],

View File

@ -72,7 +72,7 @@ describe('search index', function () {
url: 'http://localhost/ghost/tags/barcelona-tag/'
}]
});
await searchIndex.init();
let searchResults = searchIndex.search('Barcelo');
@ -93,5 +93,164 @@ describe('search index', function () {
expect(searchResults.posts.length).toEqual(0);
expect(searchResults.authors.length).toEqual(0);
expect(searchResults.tags.length).toEqual(0);
// confirms that search works in the forward direction for ltr languages:
let searchWithStartResults = searchIndex.search('Barce');
expect(searchWithStartResults.posts.length).toEqual(1);
let searchWithEndResults = searchIndex.search('celona');
expect(searchWithEndResults.posts.length).toEqual(0);
});
test('searching works when dir = rtl also', async () => {
const adminUrl = 'http://localhost:3000';
const apiKey = '69010382388f9de5869ad6e558';
const searchIndex = new SearchIndex({adminUrl, apiKey, dir: 'ltr', storage: localStorage});
nock('http://localhost:3000/ghost/api/content')
.get('/posts/?key=69010382388f9de5869ad6e558&limit=10000&fields=id%2Cslug%2Ctitle%2Cexcerpt%2Curl%2Cupdated_at%2Cvisibility&order=updated_at%20DESC')
.reply(200, {
posts: [{
id: 'sounique',
title: 'أُظهر المثابرة كل يوم',
excerpt: 'أظهر المثابرة كل يوم. كتابة الاختبارات تحدٍ كبير!',
url: 'http://localhost/ghost/awesome-barcelona-life/'
},
{
id: 'sounique2',
title: 'هذا منشور عن السعادة',
excerpt: 'هذا منشور عن السعادة. لا يتطابق مع استعلام البحث.',
url: 'http://localhost/ghost/awesome-barcelona-life2/'
}]
})
.get('/authors/?key=69010382388f9de5869ad6e558&limit=10000&fields=id,slug,name,url,profile_image&order=updated_at%20DESC')
.reply(200, {
authors: [{
id: 'different_uniq',
slug: 'barcelona-author',
name: 'اسمي المثابرة',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/barcelona-author/'
}, {
id: 'different_uniq_2',
slug: 'bob',
name: 'Bob',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/bob/'
}]
})
.get('/tags/?key=69010382388f9de5869ad6e558&&limit=10000&fields=id,slug,name,url&order=updated_at%20DESC&filter=visibility%3Apublic')
.reply(200, {
tags: [{
id: 'uniq_tag',
slug: 'barcelona-tag',
name: 'المثابرة',
url: 'http://localhost/ghost/tags/barcelona-tag/'
}]
});
await searchIndex.init();
let searchResults = searchIndex.search('المثابرة');
expect(searchResults.posts.length).toEqual(1);
expect(searchResults.posts[0].title).toEqual('أُظهر المثابرة كل يوم');
expect(searchResults.posts[0].url).toEqual('http://localhost/ghost/awesome-barcelona-life/');
expect(searchResults.authors.length).toEqual(1);
expect(searchResults.authors[0].name).toEqual('اسمي المثابرة');
expect(searchResults.authors[0].url).toEqual('http://localhost/ghost/authors/barcelona-author/');
expect(searchResults.authors[0].profile_image).toEqual('https://url_to_avatar/barcelona.png');
expect(searchResults.tags.length).toEqual(1);
expect(searchResults.tags[0].name).toEqual('المثابرة');
expect(searchResults.tags[0].url).toEqual('http://localhost/ghost/tags/barcelona-tag/');
searchResults = searchIndex.search('Nothing like this');
expect(searchResults.posts.length).toEqual(0);
expect(searchResults.authors.length).toEqual(0);
expect(searchResults.tags.length).toEqual(0);
let searchWithStartResults = searchIndex.search('المثا');
expect(searchWithStartResults.posts.length).toEqual(1);
expect(searchWithStartResults.posts[0].title).toEqual('أُظهر المثابرة كل يوم');
let searchWithEndResults = searchIndex.search('ثابرة');
expect(searchWithEndResults.posts.length).toEqual(0);
});
test('searching handles CJK characters correctly', async () => {
const adminUrl = 'http://localhost:3000';
const apiKey = '69010382388f9de5869ad6e558';
const searchIndex = new SearchIndex({adminUrl, apiKey, dir: 'ltr', storage: localStorage});
nock('http://localhost:3000/ghost/api/content')
.get('/posts/?key=69010382388f9de5869ad6e558&limit=10000&fields=id%2Cslug%2Ctitle%2Cexcerpt%2Curl%2Cupdated_at%2Cvisibility&order=updated_at%20DESC')
.reply(200, {
posts: [{
id: 'sounique',
title: '接收電子報 Regisztráljon fizetős',
excerpt: '要是系統發送電子報時遇到永久失敗的情形English 該帳號將停止接收電子報 Regisztráljon fizetős fiókot يتطابق a المثابرة كل يوم hozzásćzólások írásához あなたのリクエストはこのサイトの管理者に送信されます。Пријавете го овој коментар Dołączdo płatnej społeczności {{publication}}, by zaąćcąć komećantować. vietnamese: Yêu cầu nhà cung cấp dịch vụ email hỗ trợ bengali: নিউরো সার্জন',
url: 'http://localhost/ghost/visting-china-as-a-polyglot/'
},
{
id: 'sounique2',
title: 'هذا منشور عن السعادة',
excerpt: 'هذا منشور عن السعادة. لا يتطابق مع استعلام البحث.',
url: 'http://localhost/ghost/a-post-in-arabic/'
},
{
id: 'sounique3',
title: '毅力和运气',
excerpt: '凭借运气和毅力Cathy 将通过所有测试。',
url: 'http://localhost/ghost/a-post-in-chinese/'
}]
})
.get('/authors/?key=69010382388f9de5869ad6e558&limit=10000&fields=id,slug,name,url,profile_image&order=updated_at%20DESC')
.reply(200, {
authors: [{
id: 'different_uniq',
slug: 'barcelona-author',
name: 'Barcelona Author',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/barcelona-author/'
}, {
id: 'different_uniq_2',
slug: 'bob',
name: 'Bob',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/bob/'
}]
})
.get('/tags/?key=69010382388f9de5869ad6e558&&limit=10000&fields=id,slug,name,url&order=updated_at%20DESC&filter=visibility%3Apublic')
.reply(200, {
tags: [{
id: 'uniq_tag',
slug: 'barcelona-tag',
name: 'Barcelona Tag',
url: 'http://localhost/ghost/tags/barcelona-tag/'
}]
});
await searchIndex.init();
let searchResults = searchIndex.search('Regisztrálj');
expect(searchResults.posts.length).toEqual(1);
expect(searchResults.posts[0].url).toEqual('http://localhost/ghost/visting-china-as-a-polyglot/');
searchResults = searchIndex.search('Nothing like this');
expect(searchResults.posts.length).toEqual(0);
searchResults = searchIndex.search('報');
expect(searchResults.posts.length).toEqual(1);
expect(searchResults.posts[0].url).toEqual('http://localhost/ghost/visting-china-as-a-polyglot/');
// out of order Chinese:
searchResults = searchIndex.search('接子收電');
expect(searchResults.posts.length).toEqual(1);
expect(searchResults.posts[0].url).toEqual('http://localhost/ghost/visting-china-as-a-polyglot/');
// out of order English:
searchResults = searchIndex.search('glenish');
expect(searchResults.posts.length).toEqual(0);
});
});