From 339cb25ca63e7817ce51d81d6cd0770f06adcb26 Mon Sep 17 00:00:00 2001 From: Nicolas Froidure Date: Tue, 13 Dec 2022 14:33:28 +0100 Subject: [PATCH] feat(search): add static search --- .gitignore | 7 + package-lock.json | 47 +++ package.json | 3 + public/images/icons/arrow-down.svg | 5 + .../{uE003-arrow-left.svg => arrow-left.svg} | 8 +- .../{u0020-space.svg => arrow-right.svg} | 12 +- public/images/icons/arrow-up.svg | 5 + public/images/icons/check.svg | 15 + public/images/icons/facebook.svg | 7 + public/images/icons/feed.svg | 7 + .../icons/{uE001-github.svg => github.svg} | 0 .../icons/{uE006-google.svg => google.svg} | 0 public/images/icons/link.svg | 39 +++ public/images/icons/mail.svg | 8 + .../icons/{uE007-remove.svg => remove.svg} | 6 +- public/images/icons/target.svg | 72 +++++ public/images/icons/twitter.svg | 6 + public/images/icons/uE002-arrow-down.svg | 54 ---- public/images/icons/uE004-arrow-right.svg | 58 ---- public/images/icons/uE005-arrow-up.svg | 54 ---- public/images/icons/uE008-twitter.svg | 60 ---- public/images/icons/uE009-feed.svg | 56 ---- src/pages/conferences/index.tsx | 296 ++++++++++++------ src/utils/search.ts | 42 +++ 24 files changed, 480 insertions(+), 387 deletions(-) create mode 100755 public/images/icons/arrow-down.svg rename public/images/icons/{uE003-arrow-left.svg => arrow-left.svg} (64%) rename public/images/icons/{u0020-space.svg => arrow-right.svg} (51%) create mode 100755 public/images/icons/arrow-up.svg create mode 100644 public/images/icons/check.svg create mode 100644 public/images/icons/facebook.svg create mode 100644 public/images/icons/feed.svg rename public/images/icons/{uE001-github.svg => github.svg} (100%) rename public/images/icons/{uE006-google.svg => google.svg} (100%) create mode 100644 public/images/icons/link.svg create mode 100644 public/images/icons/mail.svg rename public/images/icons/{uE007-remove.svg => remove.svg} (84%) create mode 100644 public/images/icons/target.svg create mode 100644 public/images/icons/twitter.svg delete mode 100755 public/images/icons/uE002-arrow-down.svg delete mode 100755 public/images/icons/uE004-arrow-right.svg delete mode 100755 public/images/icons/uE005-arrow-up.svg delete mode 100644 public/images/icons/uE008-twitter.svg delete mode 100644 public/images/icons/uE009-feed.svg create mode 100644 src/utils/search.ts diff --git a/.gitignore b/.gitignore index f70484fd..fb118b38 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,10 @@ next-env.d.ts # eslint .eslintcache + +# Feeds +conferences.atom +conferences.rss + +# Search index +conferencesSearchIndex.json diff --git a/package-lock.json b/package-lock.json index 17de8a12..37decc8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,15 +16,18 @@ "eslint-config-next": "13.0.4", "feed-reader": "^6.1.3", "front-matter": "^4.0.2", + "lunr": "^2.3.9", "next": "13.0.4", "react": "18.2.0", "react-dom": "18.2.0", "remark-parse": "^10.0.1", + "swr": "^2.0.0", "typescript": "4.9.3", "unified": "^10.1.2", "yerror": "^6.1.1" }, "devDependencies": { + "@types/lunr": "^2.3.4", "@typescript-eslint/eslint-plugin": "^5.45.1", "@typescript-eslint/parser": "^5.45.1", "eslint": "^8.29.0", @@ -546,6 +549,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/lunr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.4.tgz", + "integrity": "sha512-j4x4XJwZvorEUbA519VdQ5b9AOU9TSvfi8tvxMAfP8XzNLtFex7A8vFQwqOx3WACbV0KMXbACV3cZl4/gynQ7g==", + "dev": true + }, "node_modules/@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -3011,6 +3020,11 @@ "node": ">=10" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "node_modules/mdast-util-from-markdown": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.2.0.tgz", @@ -4535,6 +4549,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.0.0.tgz", + "integrity": "sha512-IhUx5yPkX+Fut3h0SqZycnaNLXLXsb2ECFq0Y29cxnK7d8r7auY2JWNbCW3IX+EqXUg3rwNJFlhrw5Ye/b6k7w==", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, + "engines": { + "pnpm": "7" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/synckit": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", @@ -5243,6 +5271,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "@types/lunr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.4.tgz", + "integrity": "sha512-j4x4XJwZvorEUbA519VdQ5b9AOU9TSvfi8tvxMAfP8XzNLtFex7A8vFQwqOx3WACbV0KMXbACV3cZl4/gynQ7g==", + "dev": true + }, "@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -6971,6 +7005,11 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "mdast-util-from-markdown": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.2.0.tgz", @@ -7894,6 +7933,14 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, + "swr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.0.0.tgz", + "integrity": "sha512-IhUx5yPkX+Fut3h0SqZycnaNLXLXsb2ECFq0Y29cxnK7d8r7auY2JWNbCW3IX+EqXUg3rwNJFlhrw5Ye/b6k7w==", + "requires": { + "use-sync-external-store": "^1.2.0" + } + }, "synckit": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", diff --git a/package.json b/package.json index f8acf0f0..c0151ae6 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,12 @@ "eslint-config-next": "13.0.4", "feed-reader": "^6.1.3", "front-matter": "^4.0.2", + "lunr": "^2.3.9", "next": "13.0.4", "react": "18.2.0", "react-dom": "18.2.0", "remark-parse": "^10.0.1", + "swr": "^2.0.0", "typescript": "4.9.3", "unified": "^10.1.2", "yerror": "^6.1.1" @@ -77,6 +79,7 @@ "proseWrap": "always" }, "devDependencies": { + "@types/lunr": "^2.3.4", "@typescript-eslint/eslint-plugin": "^5.45.1", "@typescript-eslint/parser": "^5.45.1", "eslint": "^8.29.0", diff --git a/public/images/icons/arrow-down.svg b/public/images/icons/arrow-down.svg new file mode 100755 index 00000000..157e89da --- /dev/null +++ b/public/images/icons/arrow-down.svg @@ -0,0 +1,5 @@ + + + + diff --git a/public/images/icons/uE003-arrow-left.svg b/public/images/icons/arrow-left.svg similarity index 64% rename from public/images/icons/uE003-arrow-left.svg rename to public/images/icons/arrow-left.svg index 6f00b326..c2d470c9 100755 --- a/public/images/icons/uE003-arrow-left.svg +++ b/public/images/icons/arrow-left.svg @@ -1,4 +1,5 @@ + + id="arrow-left"> + d="M 5,75 75,25 75,125 z" + id="icon" + style="fill:#050505;fill-opacity:1;stroke:none" /> diff --git a/public/images/icons/u0020-space.svg b/public/images/icons/arrow-right.svg similarity index 51% rename from public/images/icons/u0020-space.svg rename to public/images/icons/arrow-right.svg index bfb67738..71944ea6 100755 --- a/public/images/icons/u0020-space.svg +++ b/public/images/icons/arrow-right.svg @@ -1,11 +1,15 @@ + + viewBox="0 0 80 150" + id="arrow-right"> + diff --git a/public/images/icons/arrow-up.svg b/public/images/icons/arrow-up.svg new file mode 100755 index 00000000..e3fba5b4 --- /dev/null +++ b/public/images/icons/arrow-up.svg @@ -0,0 +1,5 @@ + + + + diff --git a/public/images/icons/check.svg b/public/images/icons/check.svg new file mode 100644 index 00000000..2560b552 --- /dev/null +++ b/public/images/icons/check.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/public/images/icons/facebook.svg b/public/images/icons/facebook.svg new file mode 100644 index 00000000..459d3883 --- /dev/null +++ b/public/images/icons/facebook.svg @@ -0,0 +1,7 @@ + + + + diff --git a/public/images/icons/feed.svg b/public/images/icons/feed.svg new file mode 100644 index 00000000..c1c4b32a --- /dev/null +++ b/public/images/icons/feed.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/uE001-github.svg b/public/images/icons/github.svg similarity index 100% rename from public/images/icons/uE001-github.svg rename to public/images/icons/github.svg diff --git a/public/images/icons/uE006-google.svg b/public/images/icons/google.svg similarity index 100% rename from public/images/icons/uE006-google.svg rename to public/images/icons/google.svg diff --git a/public/images/icons/link.svg b/public/images/icons/link.svg new file mode 100644 index 00000000..c4f3e120 --- /dev/null +++ b/public/images/icons/link.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/public/images/icons/mail.svg b/public/images/icons/mail.svg new file mode 100644 index 00000000..dc4090d1 --- /dev/null +++ b/public/images/icons/mail.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/public/images/icons/uE007-remove.svg b/public/images/icons/remove.svg similarity index 84% rename from public/images/icons/uE007-remove.svg rename to public/images/icons/remove.svg index 3d53cb0b..1a617057 100755 --- a/public/images/icons/uE007-remove.svg +++ b/public/images/icons/remove.svg @@ -1,4 +1,5 @@ + + id="remove"> + id="icon" + style="fill:#050505;fill-opacity:1;stroke:none" /> diff --git a/public/images/icons/target.svg b/public/images/icons/target.svg new file mode 100644 index 00000000..0220c83d --- /dev/null +++ b/public/images/icons/target.svg @@ -0,0 +1,72 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/public/images/icons/twitter.svg b/public/images/icons/twitter.svg new file mode 100644 index 00000000..d789321f --- /dev/null +++ b/public/images/icons/twitter.svg @@ -0,0 +1,6 @@ + + + + diff --git a/public/images/icons/uE002-arrow-down.svg b/public/images/icons/uE002-arrow-down.svg deleted file mode 100755 index 0fbdbbf7..00000000 --- a/public/images/icons/uE002-arrow-down.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/public/images/icons/uE004-arrow-right.svg b/public/images/icons/uE004-arrow-right.svg deleted file mode 100755 index 9383dd0f..00000000 --- a/public/images/icons/uE004-arrow-right.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/public/images/icons/uE005-arrow-up.svg b/public/images/icons/uE005-arrow-up.svg deleted file mode 100755 index 9f818bd0..00000000 --- a/public/images/icons/uE005-arrow-up.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/public/images/icons/uE008-twitter.svg b/public/images/icons/uE008-twitter.svg deleted file mode 100644 index c11bbee0..00000000 --- a/public/images/icons/uE008-twitter.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - diff --git a/public/images/icons/uE009-feed.svg b/public/images/icons/uE009-feed.svg deleted file mode 100644 index 11d7e0bd..00000000 --- a/public/images/icons/uE009-feed.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - diff --git a/src/pages/conferences/index.tsx b/src/pages/conferences/index.tsx index 3d077cb4..285ab097 100644 --- a/src/pages/conferences/index.tsx +++ b/src/pages/conferences/index.tsx @@ -1,3 +1,6 @@ +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import lunr from 'lunr'; import { join as pathJoin } from 'path'; import Layout from '../../layouts/main'; import ContentBlock from '../../components/contentBlock'; @@ -10,6 +13,7 @@ import { toASCIIString } from '../../utils/ascii'; import { CSS_BREAKPOINT_START_L } from '../../utils/constants'; import { readParams } from '../../utils/params'; import { parseMarkdown } from '../../utils/markdown'; +import { buildSearchIndex } from '../../utils/search'; import type { FrontMatterResult } from 'front-matter'; import type { MarkdownRootNode } from '../../utils/markdown'; import type { GetStaticProps } from 'next'; @@ -56,117 +60,212 @@ type Params = BuildQueryParamsType; const POSTS_PER_PAGE = 10; +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + const BlogEntries = ({ title, description, entries, page, pagesCount, -}: Props) => ( - - - Résumés des rencontres - . - -
- {entries.map((entry) => ( -
- {entry.illustration ? ( - - - {entry.illustration.alt} +}: Props) => { + const [search, setSearch] = useState(''); + const [searchIndex, setSearchIndex] = useState(); + const [searchResults, setSearchResults] = useState< + { id: string; title: string; description: string }[] + >([]); + const { data, error, isLoading } = useSWR( + // Only load the search data when searching 🤷 + search ? '/conferencesSearchIndex.json' : null, + fetcher + ); + + useEffect(() => { + if (data) { + setSearchIndex(lunr.Index.load(data.index)); + } else { + setSearchIndex(undefined); + } + }, [data]); + + useEffect(() => { + if (searchIndex && search) { + setSearchResults( + ( + searchIndex.search(search) as { + ref: string; + score: number; + metadata: Record; + }[] + ).map((result) => ({ + result, + id: result.ref, + ...data.metadata[result.ref], + })) + ); + } else { + setSearchResults([]); + } + }, [data, searchIndex, search]); + + return ( + + + Résumés des rencontres + + Découvrez le résumé de chacune de nos rencontres ChtiJS. + + {page === 1 ? ( + + + + ) : null} + {search ? ( + error ? ( + Impossible de charger l’index de recherche. + ) : isLoading ? ( + Chargement de l’index de recherche... + ) : searchResults.length ? ( + + {searchResults.length} résultat + {searchResults.length > 1 ? 's' : ''} pour la recherche “{search} + ”. + + ) : ( + Aucun résultat pour la recherche “{search}”. + ) + ) : null} +
+ {( + (search ? searchResults : entries) as Pick< + Entry, + 'id' | 'title' | 'description' | 'illustration' + >[] + ).map((entry) => ( +
+ {entry.illustration ? ( + + + {entry.illustration.alt} + + + ) : null} + + + {entry.title} + + + {entry.description} - ) : null} - + Lire la suite +
+
+ ))} +
+ {!search ? ( +
- ))} -
- ) : null} - -
- -
-); + + @media screen and (min-width: ${CSS_BREAKPOINT_START_L}) { + img { + float: left; + width: var(--block); + margin-right: var(--gutter); + } + .clear { + clear: left; + } + } + `} + + ); +}; export const entriesToBaseProps = ( baseEntries: FrontMatterResult[] @@ -208,6 +307,11 @@ export const getStaticProps: GetStaticProps = async ({ (page - 1) * POSTS_PER_PAGE + POSTS_PER_PAGE ); + // WARNING: This is not a nice way to generate the search index + // but having scripts run in the NextJS build context is a real + // pain + await buildSearchIndex(baseProps); + return { props: { ...baseProps, diff --git a/src/utils/search.ts b/src/utils/search.ts new file mode 100644 index 00000000..676693d3 --- /dev/null +++ b/src/utils/search.ts @@ -0,0 +1,42 @@ +import { writeFile } from 'fs'; +import { promisify } from 'util'; +import { join as joinPath } from 'path'; +import lunr from 'lunr'; +import { collectMarkdownText } from './markdown'; +import type { BaseProps } from '../pages/conferences'; + +const doWriteFile = promisify(writeFile); + +const PROJECT_DIR = joinPath('.'); + +export async function buildSearchIndex(props: BaseProps) { + const idx = lunr(function () { + this.ref('id'); + this.field('title'); + this.field('description'); + this.field('contents'); + + props.entries.forEach((doc) => { + this.add({ + id: doc.id, + title: doc.title, + description: doc.description, + contents: collectMarkdownText(doc.content), + }); + }, this); + }); + + await doWriteFile( + joinPath(PROJECT_DIR, 'public', 'conferencesSearchIndex.json'), + JSON.stringify({ + index: idx, + metadata: props.entries.reduce((allMetadata, entry) => ({ + ...allMetadata, + [entry.id]: { + title: entry.title, + description: entry.description, + }, + }), {}), + }) + ); +}