1
0
Fork 0

feat(fs.dev): Initial search implementation

This commit is contained in:
Joost De Cock 2021-12-31 08:27:13 +01:00
parent 09c2b89aed
commit 8f165dbe6d
9 changed files with 612 additions and 37 deletions

View file

@ -0,0 +1,206 @@
import { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import algoliasearch from 'algoliasearch/lite';
import { useHotkeys } from 'react-hotkeys-hook'
import { InstantSearch, connectHits, connectHighlight, connectSearchBox } from 'react-instantsearch-dom'
import config from 'site/freesewing.config.js'
const searchClient = algoliasearch(config.algolia.app, config.algolia.key)
const Hits = props => {
// When we hit enter in the text field, we want to navigate to the result
// which means we must make the result links available in the input somehow
// so let's stuff them in a data attribute
const links = props.hits.map(hit => hit.page)
props.input.current.setAttribute('data-links', JSON.stringify(links))
return props.hits.map((hit, index) => (
<Hit
{...props}
hit={hit}
index={index}
len={props.hits.length}
activeLink={links[props.active]}
/>
))
}
const CustomHits = connectHits(Hits);
const Highlight = ({ highlight, attribute, hit }) => {
const parsedHit = highlight({
highlightProperty: '_highlightResult',
attribute,
hit,
});
return parsedHit.map((part, index) => part.isHighlighted
? <mark key={index}>{part.value}</mark>
: <span key={index}>{part.value}</span>
)
}
const CustomHighlight = connectHighlight(Highlight);
const Hit = props => (
<div
className={`
border-base-300
border px-3 py-1 rounded mt-1
lg:px-6 lg:py-3 lg:border-2 lg:mt-4 lg:rounded-lg
hover:bg-base-100 hover:text-base-content
${props.index === props.active
? 'bg-base-300 text-base-content bg-opacity-30'
: ''
}
`}
>
<Link href={props.hit.page}>
<a href={props.hit.page}>
<h4 className="text-lg lg:text-2xl">
{props.hit?._highlightResult?.title
? <CustomHighlight hit={props.hit} attribute='title' />
: props.hit.title
}
</h4>
<p className="text-sm lg:text-lg">
/
<b className="font-bold px-1 lg:text-lg">
{props.hit.page.split('/')[1]}
</b>
/
{props.hit.page.split('/').slice(2).join('/')}
</p>
</a>
</Link>
</div>
)
// We use this for trapping ctrl-c
let prev
const handleInputKeydown = (evt, setSearch, setActive, active, router) => {
if (evt.key === 'Escape') setSearch(false)
if (evt.key === 'ArrowDown') setActive(act => act + 1)
if (evt.key === 'ArrowUp') setActive(act => act - 1)
if (evt.key === 'c' && prev === 'Control') evt.target.value = ''
if (evt.key === 'Enter') {
// Trigger navigation
if (evt?.target?.dataset?.links) {
router.push(JSON.parse(evt.target.dataset.links)[active])
setSearch(false)
}
}
}
const SearchBox = props => {
const input = useRef(null)
const router = useRouter()
useHotkeys('ctrl+x', () => {
input.current.value = ''
})
if (input.current && props.active < 0) input.current.focus()
const { currentRefinement, isSearchStalled, refine, setSearch, setActive } = props
return (
<div>
<form noValidate action="" role="search" onSubmit={(evt) => evt.preventDefault()}>
<div className="form-control">
<label className="label hidden lg:block">
<span className="label-text">
<b> Escape</b> to exit
<span className="px-4">|</span>
<b> Up</b> or <b>Down</b> to select
<span className="px-4">|</span>
<b> Enter</b> to navigate
<span className="px-4">|</span>
<b> Ctrl+c</b> to clear
</span>
</label>
<div className="relative">
<input
ref={input}
type="search"
autoFocus={true}
value={currentRefinement}
onChange={event => refine(event.currentTarget.value)}
onKeyDown={(evt) => handleInputKeydown(evt, setSearch, setActive, props.active, router)}
className="input lg:input-lg input-bordered input-primary w-full pr-16"
placeholder='Type to search'
/>
<button
className="absolute right-0 top-0 rounded-l-none btn btn-primary lg:btn-lg"
onClick={() => props.setSearch(false)}
>X</button>
</div>
</div>
<div
className="overscroll-auto overflow-y-auto"
style={{maxHeight: 'calc(100vh - 10rem)'}}
>
{
input.current
&& input.current.value.length > 0
&& <CustomHits hitComponent={Hit} {...props} input={input}/>
}
</div>
</form>
<div className={`
bg-neutral text-neutral-content
z-20 w-full mx-auto
lg:bg-base-100 lg:border-base-200
fixed bottom-0 left-0 border-t-2
lg:hidden
`}>
<div className='px-4 py-0 flex flex-row w-full lg:py-2'>
<button
className={`btn btn-ghost btn-block`}
onClick={() => props.setSearch(false)}
>
<span className='px-2 pt-2 pb-2'>Close Search</span>
</button>
</div>
</div>
</div>
)
}
const CustomSearchBox = connectSearchBox(SearchBox);
const Search = props => {
const [active, setActive] = useState(0)
useHotkeys('esc', () => props.setSearch(false))
useHotkeys('up', () => {
if (active) setActive(act => act - 1)
})
useHotkeys('down', () => {
setActive(act => act + 1)
})
useHotkeys('down', () => {
console.log('enter', active)
})
const stateProps = {
setSearch: props.setSearch,
setMenu: props.setMenu,
active, setActive
}
return (
<div className="max-w-prose m-auto">
<h1>Search</h1>
<InstantSearch indexName={config.algolia.index} searchClient={searchClient}>
<div>
<CustomSearchBox {...stateProps}/>
</div>
</InstantSearch>
</div>
)
}
export default Search

View file

@ -0,0 +1,12 @@
const config = {
algolia: {
app: 'MA0Y5A2PF0', // Application ID
index: 'canary_freesewing.dev',
key: '589c7a7e4d9c95a4f12868581259bf3a', // Search-only API key
},
strapi: 'https://posts.freesewing.org',
monorepo: 'https://github.com/freesewing/freesewing'
}
export default config

View file

@ -21,16 +21,21 @@
"@mdx-js/react": "^2.0.0-rc.2",
"@mdx-js/runtime": "next",
"@tailwindcss/typography": "^0.5.0",
"algoliasearch": "^4.11.0",
"daisyui": "^1.16.2",
"lodash.get": "^4.4.2",
"lodash.orderby": "^4.6.0",
"lodash.set": "^4.3.2",
"netlify-cli": "^8.4.2",
"next": "latest",
"react-hotkeys-hook": "^3.4.4",
"react-instantsearch-dom": "^6.18.0",
"react-markdown": "^7.1.1",
"react-swipeable": "^6.2.0",
"react-timeago": "^6.2.1",
"rehype-highlight": "^5.0.1",
"rehype-sanitize": "^5.0.1",
"rehype-stringify": "^9.0.2",
"remark-copy-linked-files": "https://github.com/joostdecock/remark-copy-linked-files",
"remark-gfm": "^3.0.1",
"remark-jargon": "^2.19.6"

View file

@ -0,0 +1,150 @@
/*
* This will update (replace really) the Algolia index with the
* current website contents. Or at least the markdown and Strapi
* content
*
* It expects the following environment vars to be set in a
* .env file in the 'packages/freesewing.dev' folder:
*
* ALGOLIA_APP_ID -> probably MA0Y5A2PF0
* ALGOLIA_API_KEY -> Needs permission to index/create/delete
* ALGOLIA_INDEX -> Name of the index to index to
*
*/
import dotenv from 'dotenv'
import fs from 'fs'
import path from 'path'
import algoliasearch from 'algoliasearch'
import { unified } from 'unified'
import remarkParser from 'remark-parse'
import remarkCompiler from 'remark-stringify'
import remarkFrontmatter from 'remark-frontmatter'
import remarkFrontmatterExtractor from 'remark-extract-frontmatter'
import remarkRehype from 'remark-rehype'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import yaml from 'yaml'
import { getPosts } from '../../freesewing.shared/prebuild/strapi.mjs'
import { getMdxFileList } from '../../freesewing.shared/prebuild/mdx.mjs'
dotenv.config()
/*
* Initialize Algolia client
*/
const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_API_KEY)
const index = client.initIndex(process.env.ALGOLIA_INDEX)
/*
* Turn a Strapi blog post into an object ready for indexing
*/
const transformBlogpost = post => ({
objectID: `/blog/${post.slug}`,
page: `/blog/${post.slug}`,
title: post.title,
date: post.date,
slug: post.slug,
body: post.body,
author: post.author,
caption: post.caption,
type: 'blog',
})
/*
* Turn a Strapi author into an object ready for indexing
*/
const transformAuthor = author => ({
objectID: `/blog/authors/${author.name}`,
page: `/blog/authors/${author.name}`,
name: author.name,
displayname: author.displayname,
about: author.about,
})
/*
* Get and index blog posts and author info from Strapi
*/
const indexStrapiContent = async () => {
// Say hi
console.log()
console.log(`Indexing Strapi content to Algolia`)
const authors = {}
const rawPosts = await getPosts('blog', 'dev', 'en')
// Extract list of authors
for (const [slug, post] of Object.entries(rawPosts)) {
authors[post.author.slug] = transformAuthor(post.author)
rawPosts[slug].author = post.author.slug
}
// Index posts to Algolia
index
.saveObjects(Object.values(rawPosts).map(post => transformBlogpost(post)))
.then(({ objectIDs }) => console.log(objectIDs))
.catch(err => console.log(err))
// Index authors to Algolia
index
.saveObjects(Object.values(authors))
.then(({ objectIDs }) => console.log(objectIDs))
.catch(err => console.log(err))
}
/*
* Loads markdown from disk and compiles it into HTML for indexing
*/
const markdownLoader = async file => {
const md = await fs.promises.readFile(file, 'utf-8')
const page = await unified()
.use(remarkParser)
.use(remarkCompiler)
.use(remarkFrontmatter)
.use(remarkFrontmatterExtractor, { yaml: yaml.parse })
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(md)
const id = file.split('freesewing/markdown/dev').pop().slice(0, -6)
return {
objectID: id,
page: id,
title: page.data.title,
body: page.value,
type: 'docs',
}
}
/*
* Get and index markdown content
*/
const indexMarkdownContent = async () => {
// Say hi
console.log()
console.log(`Indexing Markdown content to Algolia`)
// Setup MDX root path
const mdxRoot = path.resolve('..', '..', 'markdown', 'dev')
// Get list of filenames
const list = await getMdxFileList(mdxRoot, 'en')
const pages = []
for (const file of list) pages.push(await markdownLoader(file))
// Index markdown to Algolia
index
.saveObjects(pages)
.then(({ objectIDs }) => console.log(objectIDs))
.catch(err => console.log(err))
}
const run = async () => {
await indexMarkdownContent()
await indexStrapiContent()
console.log()
}
run()