diff --git a/packages/freesewing.dev/components/search.js b/packages/freesewing.dev/components/search.js new file mode 100644 index 00000000000..82be51c7853 --- /dev/null +++ b/packages/freesewing.dev/components/search.js @@ -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) => ( + + )) +} + +const CustomHits = connectHits(Hits); + +const Highlight = ({ highlight, attribute, hit }) => { + const parsedHit = highlight({ + highlightProperty: '_highlightResult', + attribute, + hit, + }); + + return parsedHit.map((part, index) => part.isHighlighted + ? {part.value} + : {part.value} + ) +} + +const CustomHighlight = connectHighlight(Highlight); + +const Hit = props => ( +
+ + +

+ {props.hit?._highlightResult?.title + ? + : props.hit.title + } +

+

+ / + + {props.hit.page.split('/')[1]} + + / + {props.hit.page.split('/').slice(2).join('/')} +

+
+ +
+) + +// 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 ( +
+
evt.preventDefault()}> +
+ +
+ 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' + /> + +
+
+
+ { + input.current + && input.current.value.length > 0 + && + } +
+
+
+
+ +
+
+
+ ) +} + +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 ( +
+

Search

+ +
+ +
+
+
+ ) +} + +export default Search diff --git a/packages/freesewing.dev/freesewing.config.js b/packages/freesewing.dev/freesewing.config.js new file mode 100644 index 00000000000..e5e3a098edd --- /dev/null +++ b/packages/freesewing.dev/freesewing.config.js @@ -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 + diff --git a/packages/freesewing.dev/package.json b/packages/freesewing.dev/package.json index fc06d232620..4128213d904 100644 --- a/packages/freesewing.dev/package.json +++ b/packages/freesewing.dev/package.json @@ -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" diff --git a/packages/freesewing.dev/scripts/algolia.mjs b/packages/freesewing.dev/scripts/algolia.mjs new file mode 100644 index 00000000000..ac8255068de --- /dev/null +++ b/packages/freesewing.dev/scripts/algolia.mjs @@ -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() + diff --git a/packages/freesewing.shared/components/layouts/default.js b/packages/freesewing.shared/components/layouts/default.js index fce67070231..c04c57ab264 100644 --- a/packages/freesewing.shared/components/layouts/default.js +++ b/packages/freesewing.shared/components/layouts/default.js @@ -8,6 +8,7 @@ import get from 'lodash.get' // Site components import Header from 'site/components/header' import Footer from 'site/components/footer' +import Search from 'site/components/search' const iconSize= 48 @@ -59,7 +60,7 @@ const Breadcrumbs = ({ app, slug=false, title }) => { } -const DefaultLayout = ({ app, title=false, children=[]}) => { +const DefaultLayout = ({ app, title=false, children=[], search, setSearch}) => { const router = useRouter() router?.events?.on('routeChangeStart', () => app.startLoading()) @@ -117,6 +118,11 @@ const DefaultLayout = ({ app, title=false, children=[]}) => { + {search && ( +
+ +
+ )}