The Node.js® Website

Integrate Orama for search (#6257)

* feat: adds basic orama structure

* feat: adds searchbox

* feat: integrates searchbox

* style: moves components to separate files

* feat: wip on searchbox

* feat: adds basic mobile styles

* tmp: work in progress

* work in progress

* feat: improves search page

* style: addresses feedbacks on code style

* style: addresses feedbacks on code style

* feat: adds texts management via i18n

* fix: encodes URL components

* style: addresses feedback

* style: addresses feedback

* docs: adds comments to Orama sync script

* style: addresses feedback

* style: addresses feedback

* style: addresses feedback

* refactor: moves components and hooks into the correct folder structure

* refactor: moves components and hooks into the correct folder structure

* refactor: moves components and hooks into the correct folder structure

* refactor: moves components and hooks into the correct folder structure

* refactor: moves components and hooks into the correct folder structure

* refactor: moves components and hooks into the correct folder structure

* style: addresses feedback

* style: addresses feedback

* style: addresses feedback

* style: addresses feedback

* style: addresses feedback

* ci: adds Orama sync script to gh workflows

* chore: removes useless log

* style: addresses feedback and adds tests

* feat: adds footer

* fix: fixes logo in light mode

* updates orama dependencies

* chore: updates orama dependencies to latest version

* chore: updates Orama client

* fix: fixes unexpected close of modal on click

* fix: fixes Orama logo

* chore: removes unused test attribute

* fix: code-reviews

* chore: minor copy changes

* fix: aggregate results and make them unique

---------

Signed-off-by: Michele Riva <ciao@micheleriva.it>
Co-authored-by: Claudio Wunder <cwunder@gnome.org>

authored by Michele Riva Claudio Wunder and committed by GitHub a98c1cd5 6205b1a6

+5
.github/workflows/build.yml
··· 123 123 # this should be a last resort in case by any chances the build memory gets too high 124 124 # but in general this should never happen 125 125 NODE_OPTIONS: '--max_old_space_size=4096' 126 + 127 + - name: Sync Orama Cloud 128 + if: github.ref == 'refs/heads/main' 129 + run: | 130 + npm run sync-orama
+1
.gitignore
··· 2 2 node_modules 3 3 npm-debug.log 4 4 .npm 5 + .env.local 5 6 6 7 # Next.js Build Output 7 8 .next
+28 -20
app/[locale]/next-data/page-data/route.ts
··· 15 15 defaultLocale.code 16 16 ); 17 17 18 - const availablePagesMetadata = allAvailbleRoutes.map(async pathname => { 19 - const { source, filename } = await dynamicRouter.getMarkdownFile( 20 - defaultLocale.code, 21 - pathname 22 - ); 18 + const availablePagesMetadata = allAvailbleRoutes 19 + .filter(route => !route.startsWith('blog')) 20 + .map(async pathname => { 21 + const { source, filename } = await dynamicRouter.getMarkdownFile( 22 + defaultLocale.code, 23 + pathname 24 + ); 23 25 24 - // Gets the title and the Description from the Page Metadata 25 - const { title, description } = await dynamicRouter.getPageMetadata( 26 - defaultLocale.code, 27 - pathname 28 - ); 26 + // Gets the title and the Description from the Page Metadata 27 + const { title, description } = await dynamicRouter.getPageMetadata( 28 + defaultLocale.code, 29 + pathname 30 + ); 29 31 30 - // Parser the Markdown source with `gray-matter` and then only 31 - // grabs the markdown content and cleanses it by removing HTML/JSX tags 32 - // removing empty/blank lines or lines just with spaces and trims each line 33 - // from leading and trailing paddings/spaces 34 - const cleanedContent = parseRichTextIntoPlainText(matter(source).content); 32 + // Parser the Markdown source with `gray-matter` and then only 33 + // grabs the markdown content and cleanses it by removing HTML/JSX tags 34 + // removing empty/blank lines or lines just with spaces and trims each line 35 + // from leading and trailing paddings/spaces 36 + const cleanedContent = parseRichTextIntoPlainText(matter(source).content); 35 37 36 - // Deflates a String into a base64 string-encoded (zlib compressed) 37 - const deflatedSource = deflateSync(cleanedContent).toString('base64'); 38 + // Deflates a String into a base64 string-encoded (zlib compressed) 39 + const deflatedSource = deflateSync(cleanedContent).toString('base64'); 38 40 39 - // Returns metadata of each page available on the Website 40 - return { filename, pathname, title, description, content: deflatedSource }; 41 - }); 41 + // Returns metadata of each page available on the Website 42 + return { 43 + filename, 44 + pathname, 45 + title, 46 + description, 47 + content: deflatedSource, 48 + }; 49 + }); 42 50 43 51 return Response.json(await Promise.all(availablePagesMetadata)); 44 52 };
+40
components/Common/Search/States/WithAllResults.tsx
··· 1 + import type { Results } from '@orama/orama'; 2 + import NextLink from 'next/link'; 3 + import { useParams } from 'next/navigation'; 4 + import { useTranslations } from 'next-intl'; 5 + import type { FC } from 'react'; 6 + 7 + import type { SearchDoc } from '@/types'; 8 + 9 + import styles from './index.module.css'; 10 + 11 + type SearchResults = Results<SearchDoc>; 12 + 13 + type SeeAllProps = { 14 + searchResults: SearchResults; 15 + searchTerm: string; 16 + selectedFacetName: string; 17 + onSeeAllClick: () => void; 18 + }; 19 + 20 + export const WithAllResults: FC<SeeAllProps> = props => { 21 + const t = useTranslations(); 22 + const params = useParams(); 23 + 24 + const locale = params?.locale ?? 'en'; 25 + const resultsCount = props.searchResults?.count?.toLocaleString('en') ?? 0; 26 + const searchParams = new URLSearchParams(); 27 + 28 + searchParams.set('q', props.searchTerm); 29 + searchParams.set('section', props.selectedFacetName); 30 + 31 + const allResultsURL = `/${locale}/search?${searchParams.toString()}`; 32 + 33 + return ( 34 + <div className={styles.seeAllFulltextSearchResults}> 35 + <NextLink href={allResultsURL} onClick={props.onSeeAllClick}> 36 + {t('components.search.seeAll.text', { count: resultsCount })} 37 + </NextLink> 38 + </div> 39 + ); 40 + };
+14
components/Common/Search/States/WithEmptyState.tsx
··· 1 + import { useTranslations } from 'next-intl'; 2 + import type { FC } from 'react'; 3 + 4 + import styles from './index.module.css'; 5 + 6 + export const WithEmptyState: FC = () => { 7 + const t = useTranslations(); 8 + 9 + return ( 10 + <div className={styles.emptyStateContainer}> 11 + {t('components.search.emptyState.text')} 12 + </div> 13 + ); 14 + };
+14
components/Common/Search/States/WithError.tsx
··· 1 + import { useTranslations } from 'next-intl'; 2 + import type { FC } from 'react'; 3 + 4 + import styles from './index.module.css'; 5 + 6 + export const WithError: FC = () => { 7 + const t = useTranslations(); 8 + 9 + return ( 10 + <div className={styles.searchErrorContainer}> 11 + {t('components.search.searchError.text')} 12 + </div> 13 + ); 14 + };
+16
components/Common/Search/States/WithNoResults.tsx
··· 1 + import { useTranslations } from 'next-intl'; 2 + import type { FC } from 'react'; 3 + 4 + import styles from './index.module.css'; 5 + 6 + type NoResultsProps = { searchTerm: string }; 7 + 8 + export const WithNoResults: FC<NoResultsProps> = props => { 9 + const t = useTranslations(); 10 + 11 + return ( 12 + <div className={styles.noResultsContainer}> 13 + {t('components.search.noResults.text', { query: props.searchTerm })} 14 + </div> 15 + ); 16 + };
+41
components/Common/Search/States/WithPoweredBy.tsx
··· 1 + 'use client'; 2 + 3 + import Image from 'next/image'; 4 + import { useTranslations } from 'next-intl'; 5 + import { useTheme } from 'next-themes'; 6 + import { useEffect, useState } from 'react'; 7 + 8 + import styles from './index.module.css'; 9 + 10 + const getLogoURL = (theme: string = 'dark') => 11 + `https://website-assets.oramasearch.com/orama-when-${theme}.svg`; 12 + 13 + export const WithPoweredBy = () => { 14 + const t = useTranslations(); 15 + const { resolvedTheme } = useTheme(); 16 + const [logoURL, setLogoURL] = useState<string>(); 17 + 18 + useEffect(() => setLogoURL(getLogoURL(resolvedTheme)), [resolvedTheme]); 19 + 20 + return ( 21 + <div className={styles.poweredBy}> 22 + {t('components.search.poweredBy.text')} 23 + 24 + <a 25 + href="https://oramasearch.com?utm_source=nodejs.org" 26 + target="_blank" 27 + rel="noreferer" 28 + > 29 + {logoURL && ( 30 + <Image 31 + src={logoURL} 32 + alt="Powered by OramaSearch" 33 + className={styles.poweredByLogo} 34 + width={80} 35 + height={20} 36 + /> 37 + )} 38 + </a> 39 + </div> 40 + ); 41 + };
+193
components/Common/Search/States/WithSearchBox.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + MagnifyingGlassIcon, 5 + ChevronLeftIcon, 6 + } from '@heroicons/react/24/outline'; 7 + import type { Results, Nullable } from '@orama/orama'; 8 + import classNames from 'classnames'; 9 + import { useState, useRef, useEffect } from 'react'; 10 + import type { FC } from 'react'; 11 + 12 + import styles from '@/components/Common/Search/States/index.module.css'; 13 + import { WithAllResults } from '@/components/Common/Search/States/WithAllResults'; 14 + import { WithEmptyState } from '@/components/Common/Search/States/WithEmptyState'; 15 + import { WithError } from '@/components/Common/Search/States/WithError'; 16 + import { WithNoResults } from '@/components/Common/Search/States/WithNoResults'; 17 + import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy'; 18 + import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult'; 19 + import { useClickOutside } from '@/hooks/react-client'; 20 + import { useRouter } from '@/navigation.mjs'; 21 + import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs'; 22 + import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs'; 23 + import type { SearchDoc } from '@/types'; 24 + import { debounce } from '@/util/debounce'; 25 + 26 + type Facets = { [key: string]: number }; 27 + 28 + type SearchResults = Nullable<Results<SearchDoc>>; 29 + 30 + type SearchBoxProps = { onClose: () => void }; 31 + 32 + export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => { 33 + const [searchTerm, setSearchTerm] = useState(''); 34 + const [searchResults, setSearchResults] = useState<SearchResults>(null); 35 + const [selectedFacet, setSelectedFacet] = useState<number>(0); 36 + const [searchError, setSearchError] = useState<Nullable<Error>>(null); 37 + 38 + const router = useRouter(); 39 + const searchInputRef = useRef<HTMLInputElement>(null); 40 + const searchBoxRef = useRef<HTMLDivElement>(null); 41 + 42 + const search = (term: string) => { 43 + oramaSearch({ 44 + term, 45 + ...DEFAULT_ORAMA_QUERY_PARAMS, 46 + mode: 'fulltext', 47 + returning: [ 48 + 'path', 49 + 'pageSectionTitle', 50 + 'pageTitle', 51 + 'path', 52 + 'siteSection', 53 + ], 54 + ...filterBySection(), 55 + }) 56 + .then(setSearchResults) 57 + .catch(setSearchError); 58 + }; 59 + 60 + useClickOutside(searchBoxRef, () => { 61 + reset(); 62 + onClose(); 63 + }); 64 + 65 + useEffect(() => { 66 + searchInputRef.current?.focus(); 67 + 68 + getInitialFacets().then(setSearchResults).catch(setSearchError); 69 + 70 + return reset; 71 + }, []); 72 + 73 + useEffect( 74 + () => debounce(() => search(searchTerm), 1000), 75 + // we don't need to care about memoization of search function 76 + // eslint-disable-next-line react-hooks/exhaustive-deps 77 + [searchTerm, selectedFacet] 78 + ); 79 + 80 + const reset = () => { 81 + setSearchTerm(''); 82 + setSearchResults(null); 83 + setSelectedFacet(0); 84 + }; 85 + 86 + const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { 87 + e.preventDefault(); 88 + router.push(`/search?q=${searchTerm}&section=${selectedFacetName}`); 89 + onClose(); 90 + }; 91 + 92 + const changeFacet = (idx: number) => setSelectedFacet(idx); 93 + 94 + const filterBySection = () => { 95 + if (selectedFacet === 0) { 96 + return {}; 97 + } 98 + 99 + return { where: { siteSection: { eq: selectedFacetName } } }; 100 + }; 101 + 102 + const facets: Facets = { 103 + all: searchResults?.count ?? 0, 104 + ...(searchResults?.facets?.siteSection?.values ?? {}), 105 + }; 106 + 107 + const selectedFacetName = Object.keys(facets)[selectedFacet]; 108 + 109 + return ( 110 + <div className={styles.searchBoxModalContainer}> 111 + <div className={styles.searchBoxModalPanel} ref={searchBoxRef}> 112 + <div className={styles.searchBoxInnerPanel}> 113 + <div className={styles.searchBoxInputContainer}> 114 + <button 115 + onClick={onClose} 116 + className={styles.searchBoxBackIconContainer} 117 + > 118 + <ChevronLeftIcon className={styles.searchBoxBackIcon} /> 119 + </button> 120 + 121 + <MagnifyingGlassIcon 122 + className={styles.searchBoxMagnifyingGlassIcon} 123 + /> 124 + 125 + <form onSubmit={onSubmit}> 126 + <input 127 + ref={searchInputRef} 128 + type="search" 129 + className={styles.searchBoxInput} 130 + onChange={event => setSearchTerm(event.target.value)} 131 + value={searchTerm} 132 + /> 133 + </form> 134 + </div> 135 + 136 + <div className={styles.fulltextSearchSections}> 137 + {Object.keys(facets).map((facetName, idx) => ( 138 + <button 139 + key={facetName} 140 + className={classNames(styles.fulltextSearchSection, { 141 + [styles.fulltextSearchSectionSelected]: selectedFacet === idx, 142 + })} 143 + onClick={() => changeFacet(idx)} 144 + > 145 + {facetName} 146 + <span className={styles.fulltextSearchSectionCount}> 147 + ({facets[facetName].toLocaleString('en')}) 148 + </span> 149 + </button> 150 + ))} 151 + </div> 152 + 153 + <div className={styles.fulltextResultsContainer}> 154 + {searchError && <WithError />} 155 + 156 + {!searchError && !searchTerm && <WithEmptyState />} 157 + 158 + {!searchError && searchTerm && ( 159 + <> 160 + {searchResults && 161 + searchResults.count > 0 && 162 + searchResults.hits.map(hit => ( 163 + <WithSearchResult 164 + key={hit.id} 165 + hit={hit} 166 + searchTerm={searchTerm} 167 + /> 168 + ))} 169 + 170 + {searchResults && searchResults.count === 0 && ( 171 + <WithNoResults searchTerm={searchTerm} /> 172 + )} 173 + 174 + {searchResults && searchResults.count > 8 && ( 175 + <WithAllResults 176 + searchResults={searchResults} 177 + searchTerm={searchTerm} 178 + selectedFacetName={selectedFacetName} 179 + onSeeAllClick={onClose} 180 + /> 181 + )} 182 + </> 183 + )} 184 + </div> 185 + 186 + <div className={styles.fulltextSearchFooter}> 187 + <WithPoweredBy /> 188 + </div> 189 + </div> 190 + </div> 191 + </div> 192 + ); 193 + };
+42
components/Common/Search/States/WithSearchResult.tsx
··· 1 + import type { Result } from '@orama/orama'; 2 + import type { FC } from 'react'; 3 + 4 + import { pathToBreadcrumbs } from '@/components/Common/Search/utils'; 5 + import Link from '@/components/Link'; 6 + import { highlighter } from '@/next.orama.mjs'; 7 + import type { SearchDoc } from '@/types'; 8 + 9 + import styles from './index.module.css'; 10 + 11 + type SearchResultProps = { 12 + hit: Result<SearchDoc>; 13 + searchTerm: string; 14 + }; 15 + 16 + export const WithSearchResult: FC<SearchResultProps> = props => { 17 + const isAPIResult = props.hit.document.siteSection.toLowerCase() === 'api'; 18 + const basePath = isAPIResult ? 'https://nodejs.org' : ''; 19 + const path = `${basePath}/${props.hit.document.path}`; 20 + 21 + return ( 22 + <Link 23 + key={props.hit.id} 24 + href={path} 25 + className={styles.fulltextSearchResult} 26 + > 27 + <div 28 + className={styles.fulltextSearchResultTitle} 29 + dangerouslySetInnerHTML={{ 30 + __html: highlighter 31 + .highlight(props.hit.document.pageSectionTitle, props.searchTerm) 32 + .trim(125), 33 + }} 34 + /> 35 + <div className={styles.fulltextSearchResultBreadcrumb}> 36 + {pathToBreadcrumbs(props.hit.document.path).join(' > ')} 37 + {' > '} 38 + {props.hit.document.pageTitle} 39 + </div> 40 + </Link> 41 + ); 42 + };
+223
components/Common/Search/States/index.module.css
··· 1 + .searchBoxModalContainer { 2 + @apply fixed 3 + inset-0 4 + z-50 5 + flex 6 + items-center 7 + justify-center 8 + bg-neutral-900 9 + bg-opacity-90 10 + dark:bg-neutral-900 11 + dark:bg-opacity-90; 12 + } 13 + 14 + .searchBoxModalPanel { 15 + @apply fixed 16 + h-screen 17 + w-full 18 + bg-neutral-100 19 + dark:bg-neutral-950 20 + md:top-60 21 + md:h-[450px] 22 + md:max-w-3xl 23 + md:rounded-xl 24 + md:shadow-lg; 25 + } 26 + 27 + .searchBoxInnerPanel { 28 + @apply pt-12 29 + text-neutral-800 30 + dark:text-neutral-400 31 + md:pt-2; 32 + } 33 + 34 + .searchBoxMagnifyingGlassIcon { 35 + @apply absolute 36 + top-[10px] 37 + hidden 38 + size-6 39 + md:block; 40 + } 41 + 42 + .searchBoxBackIconContainer { 43 + @apply block 44 + md:hidden; 45 + } 46 + 47 + .searchBoxBackIcon { 48 + @apply absolute 49 + top-[7px] 50 + block 51 + size-6 52 + md:hidden; 53 + } 54 + 55 + .searchBoxInputContainer { 56 + @apply relative 57 + px-2 58 + md:px-4; 59 + } 60 + 61 + .searchBoxInput { 62 + @apply w-full 63 + rounded-b-none 64 + border-b 65 + border-neutral-300 66 + bg-transparent 67 + py-2 68 + pl-8 69 + pr-4 70 + focus:outline-none 71 + dark:border-neutral-900 72 + dark:text-neutral-300 73 + dark:placeholder-neutral-300; 74 + } 75 + 76 + .fulltextResultsContainer { 77 + @apply h-80 78 + overflow-auto 79 + md:px-4; 80 + } 81 + 82 + .fulltextSearchResult { 83 + @apply flex 84 + flex-col 85 + rounded-md 86 + p-2 87 + text-left 88 + text-sm 89 + hover:bg-neutral-300 90 + dark:hover:bg-neutral-900; 91 + } 92 + 93 + .fulltextSearchResultTitle { 94 + @apply text-neutral-800 95 + dark:text-neutral-300; 96 + } 97 + 98 + .fulltextSearchResultBreadcrumb { 99 + @apply mt-1 100 + text-xs 101 + capitalize 102 + text-neutral-800 103 + dark:text-neutral-600; 104 + } 105 + 106 + .fulltextSearchSections { 107 + @apply mb-1 108 + mt-2 109 + flex 110 + gap-2 111 + overflow-x-auto 112 + p-2 113 + text-xs 114 + font-semibold 115 + text-neutral-700 116 + dark:text-neutral-600 117 + md:px-4; 118 + } 119 + 120 + .fulltextSearchSection { 121 + @apply rounded-lg 122 + border-b 123 + border-transparent 124 + px-2 125 + py-1 126 + capitalize 127 + hover:bg-neutral-200 128 + dark:border-neutral-900 129 + dark:border-b-transparent 130 + dark:hover:bg-neutral-900; 131 + } 132 + 133 + .fulltextSearchSectionSelected { 134 + @apply rounded-b-none 135 + border-neutral-700 136 + text-neutral-900 137 + dark:border-neutral-700 138 + dark:text-neutral-300; 139 + } 140 + 141 + .fulltextSearchSectionCount { 142 + @apply ml-1 143 + text-neutral-500 144 + dark:text-neutral-800; 145 + } 146 + 147 + .seeAllFulltextSearchResults { 148 + @apply m-auto 149 + mb-2 150 + mt-4 151 + w-full 152 + text-center 153 + text-sm 154 + text-neutral-700 155 + hover:underline 156 + dark:text-neutral-600; 157 + } 158 + 159 + .poweredBy { 160 + @apply flex 161 + text-xs 162 + text-neutral-950 163 + dark:text-neutral-200; 164 + } 165 + 166 + .poweredByLogo { 167 + @apply ml-2 168 + w-16; 169 + } 170 + 171 + .emptyStateContainer { 172 + @apply flex 173 + h-[80%] 174 + w-full 175 + flex-col 176 + items-center 177 + justify-center 178 + text-center 179 + text-sm 180 + text-neutral-600 181 + dark:text-neutral-500; 182 + } 183 + 184 + .noResultsContainer { 185 + @apply flex 186 + h-[80%] 187 + w-full 188 + items-center 189 + justify-center 190 + text-center 191 + text-sm 192 + text-neutral-600 193 + dark:text-neutral-500; 194 + } 195 + 196 + .noResultsTerm { 197 + @apply font-semibold; 198 + } 199 + 200 + .searchErrorContainer { 201 + @apply flex 202 + h-[80%] 203 + w-full 204 + items-center 205 + justify-center 206 + text-center 207 + text-sm 208 + text-neutral-600 209 + dark:text-neutral-500; 210 + } 211 + 212 + .fulltextSearchFooter { 213 + @apply flex 214 + w-full 215 + justify-end 216 + rounded-b-xl 217 + border-t 218 + border-neutral-300 219 + bg-neutral-100 220 + p-4 221 + dark:border-neutral-900 222 + dark:bg-neutral-950; 223 + }
+28
components/Common/Search/index.module.css
··· 1 + .searchButton { 2 + @apply relative 3 + w-52 4 + rounded-md 5 + bg-neutral-100 6 + py-2 7 + pl-9 8 + pr-4 9 + text-left 10 + text-sm 11 + text-neutral-700 12 + transition-colors 13 + duration-200 14 + ease-in-out 15 + hover:bg-neutral-200 16 + hover:text-neutral-800 17 + dark:bg-neutral-900 18 + dark:text-neutral-600 19 + dark:hover:bg-neutral-800 20 + dark:hover:text-neutral-500; 21 + } 22 + 23 + .magnifyingGlassIcon { 24 + @apply absolute 25 + left-2 26 + top-[8px] 27 + size-5; 28 + }
+43
components/Common/Search/index.tsx
··· 1 + 'use client'; 2 + 3 + import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 + import { useTranslations } from 'next-intl'; 5 + import { useState, type FC } from 'react'; 6 + 7 + import { WithSearchBox } from '@/components/Common/Search/States/WithSearchBox'; 8 + import { useKeyboardCommands } from '@/hooks/react-client'; 9 + 10 + import styles from './index.module.css'; 11 + 12 + export const SearchButton: FC = () => { 13 + const [isOpen, setIsOpen] = useState(false); 14 + const t = useTranslations(); 15 + const openSearchBox = () => setIsOpen(true); 16 + const closeSearchBox = () => setIsOpen(false); 17 + 18 + useKeyboardCommands(cmd => { 19 + switch (cmd) { 20 + case 'cmd-k': 21 + openSearchBox(); 22 + break; 23 + case 'escape': 24 + closeSearchBox(); 25 + break; 26 + default: 27 + } 28 + }); 29 + 30 + return ( 31 + <> 32 + <button 33 + type="button" 34 + onClick={openSearchBox} 35 + className={styles.searchButton} 36 + > 37 + <MagnifyingGlassIcon className={styles.magnifyingGlassIcon} /> 38 + {t('components.search.searchBox.placeholder')} 39 + </button> 40 + {isOpen ? <WithSearchBox onClose={closeSearchBox} /> : null} 41 + </> 42 + ); 43 + };
+7
components/Common/Search/utils.ts
··· 1 + export const pathToBreadcrumbs = (path: string) => 2 + path 3 + .replace(/#.+$/, '') 4 + .split('/') 5 + .slice(0, -1) 6 + .map(element => element.replaceAll('-', ' ')) 7 + .filter(Boolean);
+3
components/Containers/NavBar/index.tsx
··· 7 7 import type { FC, ComponentProps } from 'react'; 8 8 9 9 import LanguageDropdown from '@/components/Common/LanguageDropDown'; 10 + import { SearchButton } from '@/components/Common/Search'; 10 11 import ThemeToggle from '@/components/Common/ThemeToggle'; 11 12 import NavItem from '@/components/Containers/NavBar/NavItem'; 12 13 import NodejsDark from '@/components/Icons/Logos/NodejsDark'; ··· 64 65 </div> 65 66 66 67 <div className={style.actionsWrapper}> 68 + <SearchButton /> 69 + 67 70 <ThemeToggle onClick={onThemeTogglerClick} /> 68 71 69 72 <LanguageDropdown
+82
components/MDX/SearchPage/index.module.css
··· 1 + .searchPageContainer { 2 + @apply mx-auto 3 + w-full 4 + px-4 5 + py-14 6 + md:max-w-screen-xl; 7 + } 8 + 9 + .searchTermContainer { 10 + @apply relative 11 + flex 12 + w-full 13 + flex-col 14 + justify-start 15 + gap-1 16 + px-6 17 + text-left 18 + md:px-0; 19 + } 20 + 21 + .searchResultsColumns { 22 + @apply relative 23 + mt-12 24 + grid 25 + gap-4 26 + md:grid-cols-[15%_1fr]; 27 + } 28 + 29 + .facetsColumn { 30 + @apply sticky 31 + top-0 32 + flex 33 + gap-4 34 + overflow-x-auto 35 + px-6 36 + capitalize 37 + md:flex-col 38 + md:px-0; 39 + } 40 + 41 + .facetCount { 42 + @apply ml-2 43 + text-sm 44 + text-neutral-500 45 + dark:text-neutral-800; 46 + } 47 + 48 + .resultsColumn { 49 + @apply flex 50 + flex-col 51 + gap-4 52 + px-2; 53 + } 54 + 55 + .searchResult { 56 + @apply flex 57 + w-full 58 + flex-col 59 + rounded-lg 60 + px-4 61 + py-2 62 + hover:bg-neutral-100 63 + dark:hover:bg-neutral-900; 64 + } 65 + 66 + .searchResultTitle { 67 + @apply text-lg; 68 + } 69 + 70 + .searchResultPageTitle { 71 + @apply text-sm 72 + capitalize 73 + text-neutral-500 74 + dark:text-neutral-600; 75 + } 76 + 77 + .searchResultSnippet { 78 + @apply my-2 79 + text-sm 80 + text-neutral-500 81 + dark:text-neutral-400; 82 + }
+135
components/MDX/SearchPage/index.tsx
··· 1 + 'use client'; 2 + 3 + import type { Nullable, Results, Result } from '@orama/orama'; 4 + import { useSearchParams } from 'next/navigation'; 5 + import { useTranslations } from 'next-intl'; 6 + import { useEffect, useState, type FC } from 'react'; 7 + 8 + import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy'; 9 + import { pathToBreadcrumbs } from '@/components/Common/Search/utils'; 10 + import Link from '@/components/Link'; 11 + import { useBottomScrollListener } from '@/hooks/react-client'; 12 + import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs'; 13 + import { search as oramaSearch, highlighter } from '@/next.orama.mjs'; 14 + import type { SearchDoc } from '@/types'; 15 + 16 + import styles from './index.module.css'; 17 + 18 + type SearchResults = Nullable<Results<SearchDoc>>; 19 + type Hit = Result<SearchDoc>; 20 + 21 + const SearchPage: FC = () => { 22 + const t = useTranslations(); 23 + const searchParams = useSearchParams(); 24 + const [searchResults, setSearchResults] = useState<SearchResults>(null); 25 + const [hits, setHits] = useState<Array<Hit>>([]); 26 + const [offset, setOffset] = useState<number>(0); 27 + 28 + const searchTerm = searchParams?.get('q'); 29 + const searchSection = searchParams?.get('section'); 30 + 31 + useBottomScrollListener(() => setOffset(offset => offset + 10)); 32 + 33 + // eslint-disable-next-line react-hooks/exhaustive-deps 34 + useEffect(() => search(offset), [offset]); 35 + 36 + useEffect(() => { 37 + setHits([]); 38 + search(0); 39 + // eslint-disable-next-line react-hooks/exhaustive-deps 40 + }, [searchSection, searchTerm]); 41 + 42 + const uniqueHits = (newHits: Array<Hit>) => 43 + newHits.filter( 44 + (obj, index) => newHits.findIndex(item => item.id === obj.id) === index 45 + ); 46 + 47 + const search = (resultsOffset = 0) => { 48 + oramaSearch({ 49 + ...DEFAULT_ORAMA_QUERY_PARAMS, 50 + mode: 'fulltext', 51 + term: searchTerm || '', 52 + limit: 10, 53 + offset: resultsOffset, 54 + ...filterBySection(), 55 + }) 56 + .then(results => { 57 + setSearchResults(results); 58 + setHits(hits => uniqueHits([...hits, ...(results?.hits ?? [])])); 59 + }) 60 + .catch(); 61 + }; 62 + 63 + const facets = { 64 + all: searchResults?.count ?? 0, 65 + ...(searchResults?.facets?.siteSection?.values ?? {}), 66 + }; 67 + 68 + const filterBySection = () => 69 + searchSection && searchSection !== 'all' 70 + ? { where: { siteSection: { eq: searchSection } } } 71 + : {}; 72 + 73 + const getDocumentURL = (path: string) => 74 + path.startsWith('api/') ? `https://nodejs.org/${path}` : path; 75 + 76 + return ( 77 + <div className={styles.searchPageContainer}> 78 + <div className={styles.searchTermContainer}> 79 + <h1> 80 + {t('components.search.searchPage.title', { query: searchTerm })} 81 + </h1> 82 + 83 + <WithPoweredBy /> 84 + </div> 85 + 86 + <div className={styles.searchResultsColumns}> 87 + <div className={styles.facetsColumn}> 88 + {Object.keys(facets).map(facetName => ( 89 + <Link 90 + key={facetName} 91 + className={styles.searchResultsFacet} 92 + href={`/search?q=${searchTerm}&section=${facetName}`} 93 + > 94 + {facetName} 95 + <span className={styles.facetCount}> 96 + ({facets[facetName as keyof typeof facets]}) 97 + </span> 98 + </Link> 99 + ))} 100 + </div> 101 + 102 + <div className={styles.resultsColumn}> 103 + {hits?.map(hit => ( 104 + <Link 105 + key={hit.id} 106 + href={getDocumentURL(hit.document.path)} 107 + className={styles.searchResult} 108 + > 109 + <div> 110 + <h2 className={styles.searchResultTitle}> 111 + {hit.document.pageSectionTitle} 112 + </h2> 113 + 114 + <p 115 + className={styles.searchResultSnippet} 116 + dangerouslySetInnerHTML={{ 117 + __html: highlighter 118 + .highlight(hit.document.pageSectionContent, searchTerm!) 119 + .trim(180), 120 + }} 121 + /> 122 + 123 + <div className={styles.searchResultPageTitle}> 124 + Home {'>'} {pathToBreadcrumbs(hit.document.path).join(' > ')} 125 + </div> 126 + </div> 127 + </Link> 128 + ))} 129 + </div> 130 + </div> 131 + </div> 132 + ); 133 + }; 134 + 135 + export default SearchPage;
+2
components/withLayout.tsx
··· 14 14 import HomeLayout from '@/layouts/New/Home'; 15 15 import LearnLayout from '@/layouts/New/Learn'; 16 16 import PostLayout from '@/layouts/New/Post'; 17 + import SearchLayout from '@/layouts/New/Search'; 17 18 import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs'; 18 19 import type { Layouts, LegacyLayouts } from '@/types'; 19 20 ··· 37 38 'page.hbs': DefaultLayout, 38 39 'blog-post.hbs': PostLayout, 39 40 'blog-category.hbs': BlogLayout, 41 + 'search.hbs': SearchLayout, 40 42 } satisfies Record<Layouts, FC>; 41 43 42 44 type WithLayout<L = Layouts | LegacyLayouts> = PropsWithChildren<{ layout: L }>;
+3
hooks/react-client/index.ts
··· 3 3 export { default as useMediaQuery } from './useMediaQuery'; 4 4 export { default as useNotification } from './useNotification'; 5 5 export { default as useClientContext } from './useClientContext'; 6 + export { default as useKeyboardCommands } from './useKeyboardCommands'; 7 + export { default as useClickOutside } from './useClickOutside'; 8 + export { default as useBottomScrollListener } from './useBottomScrollListener';
+38
hooks/react-client/useBottomScrollListener.ts
··· 1 + import { useState, useEffect } from 'react'; 2 + 3 + import { debounce } from '@/util/debounce'; 4 + 5 + type CallbackFunction = () => void; 6 + 7 + const useBottomScrollListener = ( 8 + callback: CallbackFunction, 9 + debounceTime = 300 10 + ) => { 11 + const [bottomReached, setBottomReached] = useState(false); 12 + 13 + const debouncedCallback = debounce(callback, debounceTime); 14 + 15 + const handleScroll = () => { 16 + const scrollTop = document.documentElement.scrollTop; 17 + const windowHeight = window.innerHeight; 18 + const height = document.documentElement.scrollHeight; 19 + 20 + const bottomOfWindow = Math.ceil(scrollTop + windowHeight) >= height; 21 + 22 + if (bottomOfWindow) { 23 + setBottomReached(true); 24 + debouncedCallback(); 25 + } else { 26 + setBottomReached(false); 27 + } 28 + }; 29 + 30 + useEffect(() => { 31 + window.addEventListener('scroll', handleScroll, { passive: true }); 32 + return () => window.removeEventListener('scroll', handleScroll); 33 + }, []); 34 + 35 + return bottomReached; 36 + }; 37 + 38 + export default useBottomScrollListener;
+20
hooks/react-client/useClickOutside.ts
··· 1 + import type { RefObject } from 'react'; 2 + import { useEffect } from 'react'; 3 + 4 + const useClickOutside = <T extends HTMLElement>( 5 + ref: RefObject<T>, 6 + fn: () => void 7 + ) => { 8 + useEffect(() => { 9 + const element = ref?.current; 10 + const handleClickOutside = (event: Event) => { 11 + if (element && !element.contains(event.target as Node)) { 12 + fn(); 13 + } 14 + }; 15 + document.addEventListener('click', handleClickOutside); 16 + return () => document.removeEventListener('click', handleClickOutside); 17 + }, [ref, fn]); 18 + }; 19 + 20 + export default useClickOutside;
+36
hooks/react-client/useKeyboardCommands.ts
··· 1 + import { useEffect } from 'react'; 2 + 3 + type KeyboardCommand = 'cmd-k' | 'escape' | 'down' | 'up' | 'enter'; 4 + 5 + type KeyboardCommandCallback = (key: KeyboardCommand) => void; 6 + 7 + const useKeyboardCommands = (fn: KeyboardCommandCallback) => { 8 + useEffect(() => { 9 + document.addEventListener('keydown', event => { 10 + // Detect ⌘ + k on Mac, Ctrl + k on Windows 11 + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { 12 + event.preventDefault(); 13 + fn('cmd-k'); 14 + } 15 + 16 + switch (event.key) { 17 + case 'Escape': 18 + fn('escape'); 19 + break; 20 + case 'Enter': 21 + fn('enter'); 22 + break; 23 + case 'ArrowDown': 24 + fn('down'); 25 + break; 26 + case 'ArrowUp': 27 + fn('up'); 28 + break; 29 + } 30 + }); 31 + 32 + return () => document.removeEventListener('keydown', () => {}); 33 + }, []); 34 + }; 35 + 36 + export default useKeyboardCommands;
+23
i18n/locales/en.json
··· 201 201 "changelogModal": { 202 202 "startContributing": "Start Contributing" 203 203 } 204 + }, 205 + "search": { 206 + "searchBox": { 207 + "placeholder": "Start typing..." 208 + }, 209 + "seeAll": { 210 + "text": "See all {count} results" 211 + }, 212 + "searchError": { 213 + "text": "An error occurred while searching. Please try again later." 214 + }, 215 + "poweredBy": { 216 + "text": "Powered by" 217 + }, 218 + "noResults": { 219 + "text": "No results found for \"{query}\"." 220 + }, 221 + "emptyState": { 222 + "text": "Search something..." 223 + }, 224 + "searchPage": { 225 + "title": "You're searching: {query}" 226 + } 204 227 } 205 228 }, 206 229 "layouts": {
+14
layouts/New/Search.tsx
··· 1 + import type { FC, PropsWithChildren } from 'react'; 2 + 3 + import WithFooter from '@/components/withFooter'; 4 + import WithNavBar from '@/components/withNavBar'; 5 + 6 + const SearchLayout: FC<PropsWithChildren> = ({ children }) => ( 7 + <> 8 + <WithNavBar /> 9 + <main>{children}</main> 10 + <WithFooter /> 11 + </> 12 + ); 13 + 14 + export default SearchLayout;
+43 -1
next.constants.mjs
··· 122 122 */ 123 123 export const THEME_STORAGE_KEY = 'theme'; 124 124 125 - /*** 125 + /** 126 126 * This is a list of all external links that are used on website sitemap. 127 127 * @see https://github.com/nodejs/nodejs.org/issues/5813 for more context 128 128 */ ··· 135 135 'https://trademark-list.openjsf.org/', 136 136 'https://www.linuxfoundation.org/cookies', 137 137 ]; 138 + 139 + /** 140 + * These are the default Orama Query Parameters that are used by the Website 141 + * @see https://docs.oramasearch.com/open-source/usage/search/introduction 142 + */ 143 + export const DEFAULT_ORAMA_QUERY_PARAMS = { 144 + mode: 'fulltext', 145 + limit: 8, 146 + threshold: 0, 147 + boost: { 148 + pageSectionTitle: 4, 149 + pageSectionContent: 2.5, 150 + pageTitle: 1.5, 151 + }, 152 + facets: { 153 + siteSection: {}, 154 + }, 155 + }; 156 + 157 + /** 158 + * The default batch size to use when syncing Orama Cloud 159 + */ 160 + export const ORAMA_SYNC_BATCH_SIZE = 50; 161 + 162 + /** 163 + * The default heartbeat interval to use when communicating with Orama Cloud. 164 + * Default should be 3500ms (3.5 seconds). 165 + */ 166 + export const ORAMA_CLOUD_HEARTBEAT_INTERVAL = 3500; 167 + 168 + /** 169 + * The default Orama Cloud endpoint to use when searching with Orama Cloud. 170 + */ 171 + export const ORAMA_CLOUD_ENDPOINT = 172 + process.env.NEXT_PUBLIC_ORAMA_ENDPOINT || 173 + 'https://cloud.orama.run/v1/indexes/nodejs-org-dev-hhqrzv'; 174 + 175 + /** 176 + * The default Orama Cloud API Key to use when searching with Orama Cloud. 177 + * This is a public API key and can be shared publicly on the frontend. 178 + */ 179 + export const ORAMA_CLOUD_API_KEY = process.env.NEXT_PUBLIC_ORAMA_API_KEY || '';
+3
next.mdx.use.mjs
··· 11 11 import UpcomingSummits from './components/MDX/Calendar/UpcomingSummits'; 12 12 import MDXCodeBox from './components/MDX/CodeBox'; 13 13 import MDXCodeTabs from './components/MDX/CodeTabs'; 14 + import SearchPage from './components/MDX/SearchPage'; 14 15 import WithBadge from './components/withBadge'; 15 16 import WithBanner from './components/withBanner'; 16 17 import WithNodeRelease from './components/withNodeRelease'; ··· 40 41 DownloadLink: DownloadLink, 41 42 // Renders a Button Component for `button` tags 42 43 Button: Button, 44 + // Renders a Search Page 45 + SearchPage: SearchPage, 43 46 // Renders an container for Upcoming Node.js Summits 44 47 UpcomingSummits: UpcomingSummits, 45 48 // Renders an container for Upcoming Node.js Events
+37
next.orama.mjs
··· 1 + import { Highlight } from '@orama/highlight'; 2 + import { OramaClient } from '@oramacloud/client'; 3 + 4 + import { 5 + DEFAULT_ORAMA_QUERY_PARAMS, 6 + ORAMA_CLOUD_HEARTBEAT_INTERVAL, 7 + ORAMA_CLOUD_ENDPOINT, 8 + ORAMA_CLOUD_API_KEY, 9 + } from './next.constants.mjs'; 10 + 11 + // Provides a safe-wrapper that initialises the OramaClient 12 + // based on the presence of environmental variables 13 + const { search, getInitialFacets } = (() => { 14 + if (ORAMA_CLOUD_ENDPOINT && ORAMA_CLOUD_API_KEY) { 15 + const orama = new OramaClient({ 16 + endpoint: ORAMA_CLOUD_ENDPOINT, 17 + api_key: ORAMA_CLOUD_API_KEY, 18 + }); 19 + 20 + orama.startHeartBeat({ frequency: ORAMA_CLOUD_HEARTBEAT_INTERVAL }); 21 + 22 + return { 23 + search: orama.search.bind(orama), 24 + getInitialFacets: async () => 25 + orama.search({ term: '', ...DEFAULT_ORAMA_QUERY_PARAMS }).catch(), 26 + }; 27 + } 28 + 29 + return { search: async () => null, getInitialFacets: async () => null }; 30 + })(); 31 + 32 + export { search, getInitialFacets }; 33 + 34 + export const highlighter = new Highlight({ 35 + CSSClass: 'font-bold', 36 + HTMLTag: 'span', 37 + });
+191 -9
package-lock.json
··· 1 1 { 2 - "name": "nodejsorg", 2 + "name": "nodejs.org", 3 3 "lockfileVersion": 3, 4 4 "requires": true, 5 5 "packages": { ··· 9 9 "@heroicons/react": "~2.1.1", 10 10 "@mdx-js/mdx": "^3.0.0", 11 11 "@nodevu/core": "~0.1.0", 12 + "@orama/highlight": "^0.1.3", 13 + "@oramacloud/client": "^1.0.9", 12 14 "@radix-ui/react-accessible-icon": "^1.0.3", 13 15 "@radix-ui/react-avatar": "^1.0.4", 14 16 "@radix-ui/react-dialog": "^1.0.5", ··· 730 732 "version": "7.23.6", 731 733 "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", 732 734 "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", 733 - "dev": true, 734 735 "bin": { 735 736 "parser": "bin/babel-parser.js" 736 737 }, ··· 4032 4033 "node": ">= 10" 4033 4034 } 4034 4035 }, 4036 + "node_modules/@noble/hashes": { 4037 + "version": "1.3.3", 4038 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", 4039 + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", 4040 + "engines": { 4041 + "node": ">= 16" 4042 + }, 4043 + "funding": { 4044 + "url": "https://paulmillr.com/funding/" 4045 + } 4046 + }, 4035 4047 "node_modules/@nodelib/fs.scandir": { 4036 4048 "version": "2.1.5", 4037 4049 "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", ··· 4145 4157 "dev": true, 4146 4158 "engines": { 4147 4159 "node": "^14.17.0 || ^16.13.0 || >=18.0.0" 4160 + } 4161 + }, 4162 + "node_modules/@orama/highlight": { 4163 + "version": "0.1.3", 4164 + "resolved": "https://registry.npmjs.org/@orama/highlight/-/highlight-0.1.3.tgz", 4165 + "integrity": "sha512-KmqMkSaGZxKnS2UiK1/nacu7+D+wadT+irgBdIBoda5BkDVPPsIXwIta0ISKmZRaM3GnUs2oKx3KteYojBkIVA==", 4166 + "dependencies": { 4167 + "@orama/orama": "^2.0.0-beta.1" 4168 + } 4169 + }, 4170 + "node_modules/@orama/orama": { 4171 + "version": "2.0.3", 4172 + "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-2.0.3.tgz", 4173 + "integrity": "sha512-8BXTrXqP+kcyIExipZyf6voB3pzGPREh1BUrIqEP7V4PJwN/SnEcLJsafyPiPFM23fPSyH9krwLrXzvisLL19A==", 4174 + "engines": { 4175 + "node": ">= 16.0.0" 4176 + } 4177 + }, 4178 + "node_modules/@oramacloud/client": { 4179 + "version": "1.0.9", 4180 + "resolved": "https://registry.npmjs.org/@oramacloud/client/-/client-1.0.9.tgz", 4181 + "integrity": "sha512-qBYzppjtFfINYHoBRito8hLKJO5KbYswzZYvldBrLZoxSLrPluqt+vW4Ex8E0VhyvqPaezu8koYc79aqBLLEHA==", 4182 + "dependencies": { 4183 + "@orama/orama": "^2.0.1", 4184 + "@paralleldrive/cuid2": "^2.2.1", 4185 + "lodash": "^4.17.21", 4186 + "lodash.debounce": "^4.0.8", 4187 + "lodash.throttle": "^4.1.1", 4188 + "react": "^18.2.0", 4189 + "vue": "^3.3.4" 4190 + } 4191 + }, 4192 + "node_modules/@paralleldrive/cuid2": { 4193 + "version": "2.2.2", 4194 + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", 4195 + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", 4196 + "dependencies": { 4197 + "@noble/hashes": "^1.1.5" 4148 4198 } 4149 4199 }, 4150 4200 "node_modules/@pkgjs/parseargs": { ··· 8530 8580 } 8531 8581 } 8532 8582 }, 8583 + "node_modules/@vue/compiler-core": { 8584 + "version": "3.4.14", 8585 + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.14.tgz", 8586 + "integrity": "sha512-ro4Zzl/MPdWs7XwxT7omHRxAjMbDFRZEEjD+2m3NBf8YzAe3HuoSEZosXQo+m1GQ1G3LQ1LdmNh1RKTYe+ssEg==", 8587 + "dependencies": { 8588 + "@babel/parser": "^7.23.6", 8589 + "@vue/shared": "3.4.14", 8590 + "entities": "^4.5.0", 8591 + "estree-walker": "^2.0.2", 8592 + "source-map-js": "^1.0.2" 8593 + } 8594 + }, 8595 + "node_modules/@vue/compiler-core/node_modules/estree-walker": { 8596 + "version": "2.0.2", 8597 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 8598 + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" 8599 + }, 8600 + "node_modules/@vue/compiler-dom": { 8601 + "version": "3.4.14", 8602 + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.14.tgz", 8603 + "integrity": "sha512-nOZTY+veWNa0DKAceNWxorAbWm0INHdQq7cejFaWM1WYnoNSJbSEKYtE7Ir6lR/+mo9fttZpPVI9ZFGJ1juUEQ==", 8604 + "dependencies": { 8605 + "@vue/compiler-core": "3.4.14", 8606 + "@vue/shared": "3.4.14" 8607 + } 8608 + }, 8609 + "node_modules/@vue/compiler-sfc": { 8610 + "version": "3.4.14", 8611 + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.14.tgz", 8612 + "integrity": "sha512-1vHc9Kv1jV+YBZC/RJxQJ9JCxildTI+qrhtDh6tPkR1O8S+olBUekimY0km0ZNn8nG1wjtFAe9XHij+YLR8cRQ==", 8613 + "dependencies": { 8614 + "@babel/parser": "^7.23.6", 8615 + "@vue/compiler-core": "3.4.14", 8616 + "@vue/compiler-dom": "3.4.14", 8617 + "@vue/compiler-ssr": "3.4.14", 8618 + "@vue/shared": "3.4.14", 8619 + "estree-walker": "^2.0.2", 8620 + "magic-string": "^0.30.5", 8621 + "postcss": "^8.4.33", 8622 + "source-map-js": "^1.0.2" 8623 + } 8624 + }, 8625 + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { 8626 + "version": "2.0.2", 8627 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 8628 + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" 8629 + }, 8630 + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { 8631 + "version": "0.30.5", 8632 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", 8633 + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", 8634 + "dependencies": { 8635 + "@jridgewell/sourcemap-codec": "^1.4.15" 8636 + }, 8637 + "engines": { 8638 + "node": ">=12" 8639 + } 8640 + }, 8641 + "node_modules/@vue/compiler-ssr": { 8642 + "version": "3.4.14", 8643 + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.14.tgz", 8644 + "integrity": "sha512-bXT6+oAGlFjTYVOTtFJ4l4Jab1wjsC0cfSfOe2B4Z0N2vD2zOBSQ9w694RsCfhjk+bC2DY5Gubb1rHZVii107Q==", 8645 + "dependencies": { 8646 + "@vue/compiler-dom": "3.4.14", 8647 + "@vue/shared": "3.4.14" 8648 + } 8649 + }, 8650 + "node_modules/@vue/reactivity": { 8651 + "version": "3.4.14", 8652 + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.14.tgz", 8653 + "integrity": "sha512-xRYwze5Q4tK7tT2J4uy4XLhK/AIXdU5EBUu9PLnIHcOKXO0uyXpNNMzlQKuq7B+zwtq6K2wuUL39pHA6ZQzObw==", 8654 + "dependencies": { 8655 + "@vue/shared": "3.4.14" 8656 + } 8657 + }, 8658 + "node_modules/@vue/runtime-core": { 8659 + "version": "3.4.14", 8660 + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.14.tgz", 8661 + "integrity": "sha512-qu+NMkfujCoZL6cfqK5NOfxgXJROSlP2ZPs4CTcVR+mLrwl4TtycF5Tgo0QupkdBL+2kigc6EsJlTcuuZC1NaQ==", 8662 + "dependencies": { 8663 + "@vue/reactivity": "3.4.14", 8664 + "@vue/shared": "3.4.14" 8665 + } 8666 + }, 8667 + "node_modules/@vue/runtime-dom": { 8668 + "version": "3.4.14", 8669 + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.14.tgz", 8670 + "integrity": "sha512-B85XmcR4E7XsirEHVqhmy4HPbRT9WLFWV9Uhie3OapV9m1MEN9+Er6hmUIE6d8/l2sUygpK9RstFM2bmHEUigA==", 8671 + "dependencies": { 8672 + "@vue/runtime-core": "3.4.14", 8673 + "@vue/shared": "3.4.14", 8674 + "csstype": "^3.1.3" 8675 + } 8676 + }, 8677 + "node_modules/@vue/server-renderer": { 8678 + "version": "3.4.14", 8679 + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.14.tgz", 8680 + "integrity": "sha512-pwSKXQfYdJBTpvWHGEYI+akDE18TXAiLcGn+Q/2Fj8wQSHWztoo7PSvfMNqu6NDhp309QXXbPFEGCU5p85HqkA==", 8681 + "dependencies": { 8682 + "@vue/compiler-ssr": "3.4.14", 8683 + "@vue/shared": "3.4.14" 8684 + }, 8685 + "peerDependencies": { 8686 + "vue": "3.4.14" 8687 + } 8688 + }, 8689 + "node_modules/@vue/shared": { 8690 + "version": "3.4.14", 8691 + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.14.tgz", 8692 + "integrity": "sha512-nmi3BtLpvqXAWoRZ6HQ+pFJOHBU4UnH3vD3opgmwXac7vhaHKA9nj1VeGjMggdB9eLtW83eHyPCmOU1qzdsC7Q==" 8693 + }, 8533 8694 "node_modules/@webassemblyjs/ast": { 8534 8695 "version": "1.11.6", 8535 8696 "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", ··· 11309 11470 "node_modules/csstype": { 11310 11471 "version": "3.1.3", 11311 11472 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 11312 - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 11313 - "devOptional": true 11473 + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" 11314 11474 }, 11315 11475 "node_modules/damerau-levenshtein": { 11316 11476 "version": "1.0.8", ··· 12031 12191 "version": "4.5.0", 12032 12192 "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 12033 12193 "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 12034 - "dev": true, 12035 12194 "engines": { 12036 12195 "node": ">=0.12" 12037 12196 }, ··· 18166 18325 "node_modules/lodash": { 18167 18326 "version": "4.17.21", 18168 18327 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 18169 - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", 18170 - "dev": true 18328 + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 18171 18329 }, 18172 18330 "node_modules/lodash.debounce": { 18173 18331 "version": "4.0.8", 18174 18332 "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", 18175 - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", 18176 - "dev": true 18333 + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" 18177 18334 }, 18178 18335 "node_modules/lodash.merge": { 18179 18336 "version": "4.6.2", 18180 18337 "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 18181 18338 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 18182 18339 "dev": true 18340 + }, 18341 + "node_modules/lodash.throttle": { 18342 + "version": "4.1.1", 18343 + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", 18344 + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" 18183 18345 }, 18184 18346 "node_modules/lodash.truncate": { 18185 18347 "version": "4.4.2", ··· 34402 34564 "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", 34403 34565 "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", 34404 34566 "dev": true 34567 + }, 34568 + "node_modules/vue": { 34569 + "version": "3.4.14", 34570 + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.14.tgz", 34571 + "integrity": "sha512-Rop5Al/ZcBbBz+KjPZaZDgHDX0kUP4duEzDbm+1o91uxYUNmJrZSBuegsNIJvUGy+epLevNRNhLjm08VKTgGyw==", 34572 + "dependencies": { 34573 + "@vue/compiler-dom": "3.4.14", 34574 + "@vue/compiler-sfc": "3.4.14", 34575 + "@vue/runtime-dom": "3.4.14", 34576 + "@vue/server-renderer": "3.4.14", 34577 + "@vue/shared": "3.4.14" 34578 + }, 34579 + "peerDependencies": { 34580 + "typescript": "*" 34581 + }, 34582 + "peerDependenciesMeta": { 34583 + "typescript": { 34584 + "optional": true 34585 + } 34586 + } 34405 34587 }, 34406 34588 "node_modules/w3c-xmlserializer": { 34407 34589 "version": "4.0.0",
+3
package.json
··· 29 29 "prettier": "prettier \"**/*.{js,mjs,ts,tsx,md,mdx,json,yml,css}\" --check --cache --cache-strategy=content --cache-location=.prettiercache", 30 30 "prettier:fix": "npm run prettier -- --write", 31 31 "format": "npm run lint:fix && npm run prettier:fix", 32 + "sync-orama": "node ./scripts/orama-search/sync-orama-cloud.mjs", 32 33 "storybook": "cross-env NODE_NO_WARNINGS=1 storybook dev -p 6006 --quiet --no-open", 33 34 "storybook:build": "cross-env NODE_NO_WARNINGS=1 storybook build --quiet --webpack-stats-json", 34 35 "test:unit": "cross-env NODE_NO_WARNINGS=1 jest", ··· 40 41 "@heroicons/react": "~2.1.1", 41 42 "@mdx-js/mdx": "^3.0.0", 42 43 "@nodevu/core": "~0.1.0", 44 + "@orama/highlight": "^0.1.3", 45 + "@oramacloud/client": "^1.0.9", 43 46 "@radix-ui/react-accessible-icon": "^1.0.3", 44 47 "@radix-ui/react-avatar": "^1.0.4", 45 48 "@radix-ui/react-dialog": "^1.0.5",
+6
pages/en/search.mdx
··· 1 + --- 2 + layout: search.hbs 3 + title: Search Results 4 + --- 5 + 6 + <SearchPage />
+76
scripts/orama-search/get-documents.mjs
··· 1 + import { existsSync, readFileSync } from 'node:fs'; 2 + import { join } from 'node:path'; 3 + import zlib from 'node:zlib'; 4 + 5 + import { slug } from 'github-slugger'; 6 + 7 + import { getRelativePath } from '../../next.helpers.mjs'; 8 + 9 + const currentRoot = getRelativePath(import.meta.url); 10 + const dataBasePath = join(currentRoot, '../../.next/server/app/en/next-data'); 11 + 12 + if (!existsSync(dataBasePath)) { 13 + throw new Error( 14 + 'The data directory does not exist. Please run `npm run build` first.' 15 + ); 16 + } 17 + 18 + const nextPageData = readFileSync(`${dataBasePath}/page-data.body`, 'utf-8'); 19 + const nextAPIPageData = readFileSync(`${dataBasePath}/api-data.body`, 'utf-8'); 20 + 21 + const pageData = JSON.parse(nextPageData); 22 + const apiData = JSON.parse(nextAPIPageData); 23 + 24 + const splitIntoSections = markdownContent => { 25 + const lines = markdownContent.split(/\n/gm); 26 + const sections = []; 27 + 28 + let section = null; 29 + 30 + for (const line of lines) { 31 + if (line.match(/^#{1,6}\s/)) { 32 + section = { 33 + pageSectionTitle: line.replace(/^#{1,6}\s*/, ''), 34 + pageSectionContent: [], 35 + }; 36 + 37 + sections.push(section); 38 + } else if (section) { 39 + section.pageSectionContent.push(line); 40 + } 41 + } 42 + 43 + return sections.map(section => ({ 44 + ...section, 45 + pageSectionContent: section.pageSectionContent.join('\n'), 46 + })); 47 + }; 48 + 49 + const getPageTitle = data => 50 + data.title || 51 + data.pathname 52 + .split('/') 53 + .pop() 54 + .replace(/\.html$/, '') 55 + .replace(/-/g, ' '); 56 + 57 + export const siteContent = [...pageData, ...apiData] 58 + .map(data => { 59 + const { pathname, title = getPageTitle(data), content } = data; 60 + const markdownContent = zlib 61 + .inflateSync(Buffer.from(content, 'base64')) 62 + .toString('utf-8'); 63 + 64 + const siteSection = pathname.split('/').shift(); 65 + const subSections = splitIntoSections(markdownContent); 66 + 67 + return subSections.map(section => { 68 + return { 69 + path: pathname + '#' + slug(section.pageSectionTitle), 70 + siteSection, 71 + pageTitle: title, 72 + ...section, 73 + }; 74 + }); 75 + }) 76 + .flat();
+65
scripts/orama-search/sync-orama-cloud.mjs
··· 1 + import { siteContent } from './get-documents.mjs'; 2 + import { ORAMA_SYNC_BATCH_SIZE } from '../../next.constants.mjs'; 3 + 4 + // The following follows the instructions at https://docs.oramasearch.com/cloud/data-sources/custom-integrations/webhooks 5 + 6 + const INDEX_ID = process.env.ORAMA_INDEX_ID; 7 + const API_KEY = process.env.ORAMA_SECRET_KEY; 8 + const ORAMA_API_BASE_URL = `https://api.oramasearch.com/api/v1/webhooks/${INDEX_ID}`; 9 + 10 + const oramaHeaders = { 11 + 'Content-Type': 'application/json', 12 + Authorization: `Bearer ${API_KEY}`, 13 + }; 14 + 15 + // Orama allows to send several documents at once, so we batch them in groups of 50. 16 + // This is not strictly necessary, but it makes the process faster. 17 + const runUpdate = async () => { 18 + const batchSize = ORAMA_SYNC_BATCH_SIZE; 19 + const batches = []; 20 + 21 + for (let i = 0; i < siteContent.length; i += batchSize) { 22 + batches.push(siteContent.slice(i, i + batchSize)); 23 + } 24 + 25 + await Promise.all(batches.map(insertBatch)); 26 + }; 27 + 28 + // We call the "notify" API to upsert the documents in the index. 29 + // Orama will keep a queue of all the documents we send, and will process them once we call the "deploy" API. 30 + // Full docs on the "notify" API: https://docs.oramasearch.com/cloud/data-sources/custom-integrations/webhooks#updating-removing-inserting-elements-in-a-live-index 31 + const insertBatch = async batch => 32 + await fetch(`${ORAMA_API_BASE_URL}/notify`, { 33 + method: 'POST', 34 + headers: oramaHeaders, 35 + body: JSON.stringify({ upsert: batch }), 36 + }); 37 + 38 + // We call the "deploy" API to trigger a deployment of the index, which will process all the documents in the queue. 39 + // Full docs on the "deploy" API: https://docs.oramasearch.com/cloud/data-sources/custom-integrations/webhooks#deploying-the-index 40 + const triggerDeployment = async () => 41 + await fetch(`${ORAMA_API_BASE_URL}/deploy`, { 42 + method: 'POST', 43 + headers: oramaHeaders, 44 + }); 45 + 46 + // We call the "snapshot" API to empty the index before inserting the new documents. 47 + // The "snapshot" API is typically used to replace the entire index with a fresh set of documents, but we use it here to empty the index. 48 + // This operation gets queued, so the live index will still be available until we call the "deploy" API and redeploy the index. 49 + // Full docs on the "snapshot" API: https://docs.oramasearch.com/cloud/data-sources/custom-integrations/webhooks#inserting-a-snapshot 50 + const emptyOramaIndex = async () => 51 + await fetch(`${ORAMA_API_BASE_URL}/snapshot`, { 52 + method: 'POST', 53 + headers: oramaHeaders, 54 + body: JSON.stringify([]), 55 + }); 56 + 57 + // Now we proceed to call the APIs in order: 58 + // 1. Empty the index 59 + // 2. Insert the documents 60 + // 3. Trigger a deployment 61 + // Once all these steps are done, the new documents will be available in the live index. 62 + // Allow Orama up to 1 minute to distribute the documents to all the 300+ nodes worldwide. 63 + await emptyOramaIndex(); 64 + await runUpdate(); 65 + await triggerDeployment();
+8
turbo.json
··· 13 13 "NEXT_PUBLIC_DIST_URL", 14 14 "NEXT_PUBLIC_DOCS_URL", 15 15 "NEXT_PUBLIC_BASE_PATH", 16 + "NEXT_PUBLIC_ORAMA_API_KEY", 17 + "NEXT_PUBLIC_ORAMA_ENDPOINT", 16 18 "NEXT_PUBLIC_VERCEL_REVALIDATE_TIME", 17 19 "NEXT_PUBLIC_ENABLE_REDESIGN", 18 20 "NEXT_PUBLIC_DATA_URL" ··· 35 37 "NEXT_PUBLIC_DIST_URL", 36 38 "NEXT_PUBLIC_DOCS_URL", 37 39 "NEXT_PUBLIC_BASE_PATH", 40 + "NEXT_PUBLIC_ORAMA_API_KEY", 41 + "NEXT_PUBLIC_ORAMA_ENDPOINT", 38 42 "NEXT_PUBLIC_VERCEL_REVALIDATE_TIME", 39 43 "NEXT_PUBLIC_ENABLE_REDESIGN", 40 44 "NEXT_PUBLIC_DATA_URL" ··· 51 55 "NEXT_PUBLIC_DIST_URL", 52 56 "NEXT_PUBLIC_DOCS_URL", 53 57 "NEXT_PUBLIC_BASE_PATH", 58 + "NEXT_PUBLIC_ORAMA_API_KEY", 59 + "NEXT_PUBLIC_ORAMA_ENDPOINT", 54 60 "NEXT_PUBLIC_VERCEL_REVALIDATE_TIME", 55 61 "NEXT_PUBLIC_ENABLE_REDESIGN", 56 62 "NEXT_PUBLIC_DATA_URL" ··· 73 79 "NEXT_PUBLIC_DIST_URL", 74 80 "NEXT_PUBLIC_DOCS_URL", 75 81 "NEXT_PUBLIC_BASE_PATH", 82 + "NEXT_PUBLIC_ORAMA_API_KEY", 83 + "NEXT_PUBLIC_ORAMA_ENDPOINT", 76 84 "NEXT_PUBLIC_VERCEL_REVALIDATE_TIME", 77 85 "NEXT_PUBLIC_ENABLE_REDESIGN", 78 86 "NEXT_PUBLIC_DATA_URL"
+1
types/index.ts
··· 10 10 export * from './server'; 11 11 export * from './github'; 12 12 export * from './calendar'; 13 + export * from './search';
+2 -1
types/layouts.ts
··· 5 5 | 'learn.hbs' 6 6 | 'page.hbs' 7 7 | 'blog-category.hbs' 8 - | 'blog-post.hbs'; 8 + | 'blog-post.hbs' 9 + | 'search.hbs'; 9 10 10 11 // @TODO: These are legacy layouts that are going to be replaced with the `nodejs/nodejs.dev` Layouts in the future 11 12 export type LegacyLayouts =
+8
types/search.ts
··· 1 + export interface SearchDoc { 2 + id: string; 3 + path: string; 4 + pageTitle: string; 5 + siteSection: string; 6 + pageSectionTitle: string; 7 + pageSectionContent: string; 8 + }
+50 -1
util/__tests__/stringUtils.test.mjs
··· 1 - import { getAcronymFromString } from '@/util/stringUtils'; 1 + import { 2 + getAcronymFromString, 3 + parseRichTextIntoPlainText, 4 + } from '@/util/stringUtils'; 2 5 3 6 describe('String utils', () => { 4 7 it('getAcronymFromString returns the correct acronym', () => { ··· 11 14 12 15 it('getAcronymFromString if the string is empty, it returns NA', () => { 13 16 expect(getAcronymFromString('')).toBe(''); 17 + }); 18 + 19 + it('parseRichTextIntoPlainText returns the correct plain text from an HTML tag', () => { 20 + expect(parseRichTextIntoPlainText('<p>John Doe</p>')).toBe('John Doe'); 21 + }); 22 + 23 + it('parseRichTextIntoPlainText returns only the text of a link tag', () => { 24 + expect( 25 + parseRichTextIntoPlainText('[this is a link](https://www.google.com)') 26 + ).toBe('this is a link'); 27 + }); 28 + 29 + it('parseRichTextIntoPlainText replaces markdown lists with their content', () => { 30 + expect( 31 + parseRichTextIntoPlainText('- this is a list item\n- this is another') 32 + ).toBe('this is a list item\nthis is another'); 33 + }); 34 + 35 + it('parseRichTextIntoPlainText removes underscore, bold and italic with their content', () => { 36 + expect( 37 + parseRichTextIntoPlainText( 38 + '**bold content**, *italic content*, _underscore content_' 39 + ) 40 + ).toBe('bold content, italic content, underscore content'); 41 + }); 42 + 43 + it('parseRichTextIntoPlainText removes code blocks with their content', () => { 44 + expect( 45 + parseRichTextIntoPlainText('this is a\n```code block```\nwith content') 46 + ).toBe('this is a\nwith content'); 47 + }); 48 + 49 + it('parseRichTextIntoPlainText replaces empty lines or lines just with spaces with an empty string', () => { 50 + expect(parseRichTextIntoPlainText('\n \n')).toBe(''); 51 + }); 52 + 53 + it('parseRichTextIntoPlainText replaces leading and trailing spaces from each line with an empty string', () => { 54 + expect(parseRichTextIntoPlainText(' this is a line ')).toBe( 55 + 'this is a line' 56 + ); 57 + }); 58 + 59 + it('parseRichTextIntoPlainText replaces leading numbers and dots from each line with an empty string', () => { 60 + expect( 61 + parseRichTextIntoPlainText('1. this is a line\n2. this is a second line') 62 + ).toBe('this is a line\nthis is a second line'); 14 63 }); 15 64 });
+16
util/debounce.ts
··· 1 + type DebounceFunction<T = unknown> = (...args: Array<T>) => void; 2 + 3 + export const debounce = <T extends DebounceFunction>( 4 + func: T, 5 + delay: number 6 + ): ((...args: Parameters<T>) => void) => { 7 + let timeoutId: NodeJS.Timeout; 8 + 9 + return (...args: Parameters<T>) => { 10 + clearTimeout(timeoutId); 11 + 12 + timeoutId = setTimeout(() => { 13 + func(...args); 14 + }, delay); 15 + }; 16 + };
+5 -3
util/stringUtils.ts
··· 12 12 // replaces Markdown lists with their content 13 13 .replace(/^[*-] (.*)$/gm, '$1') 14 14 // replaces Markdown underscore, bold and italic with their content 15 - .replace(/[_*]{1,2}(.*)[_*]{1,2}/gm, '$1') 15 + .replace(/(\*\*|\*|__|_)(.*?)\1/gm, '$2') 16 16 // replaces Markdown multiline codeblocks with their content 17 17 .replace(/```.+?```/gms, '') 18 - // replaces emppty lines or lines just with spaces with an empty string 18 + // replaces empty lines or lines just with spaces with an empty string 19 19 .replace(/^\s*\n/gm, '') 20 20 // replaces leading and trailing spaces from each line with an empty string 21 - .replace(/^[ ]+|[ ]+$/gm, ''); 21 + .replace(/^[ ]+|[ ]+$/gm, '') 22 + // replaces leading numbers and dots from each line with an empty string 23 + .replace(/^\d+\.\s/gm, '');