Skip to content

Commit

Permalink
Merge pull request #340 from shuding/shu/6f5e
Browse files Browse the repository at this point in the history
Group search results by page and other improvements
  • Loading branch information
shuding authored Jan 23, 2022
2 parents 78fc2b6 + 05cfc39 commit 839b1c6
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 40 deletions.
122 changes: 92 additions & 30 deletions packages/nextra-theme-docs/src/flexsearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,32 @@ import Link from 'next/link'
import FlexSearch from 'flexsearch'
import { Transition } from '@headlessui/react/dist/index.esm'

const Item = ({ page, title, active, href, onMouseOver, excerpt }) => {
import { useConfig } from './config'
import renderComponent from './utils/render-component'

const Item = ({ page, first, title, active, href, onHover, excerpt }) => {
return (
<Link href={href}>
<a className="block no-underline" onMouseOver={onMouseOver}>
<li className={cn({ active })}>
<div className="font-bold uppercase text-xs text-gray-400">
{page}
</div>
<div className="font-semibold dark:text-white">{title}</div>
{excerpt ? (
<div className="excerpt mt-1 text-gray-600 text-sm leading-[1.35rem] dark:text-gray-400">
{excerpt}
<>
{first ? (
<div className="mx-2.5 px-2.5 pb-1.5 mb-2 mt-6 first:mt-0 border-b font-semibold uppercase text-xs text-gray-500 select-none dark:text-gray-300 dark:border-opacity-10">
{page}
</div>
) : null}
<Link href={href}>
<a className="block no-underline" onMouseMove={onHover}>
<li className={cn({ active })}>
<div className="font-semibold dark:text-white leading-5">
{title}
</div>
) : null}
</li>
</a>
</Link>
{excerpt ? (
<div className="excerpt mt-1 text-gray-600 text-sm leading-[1.35rem] dark:text-gray-400">
{excerpt}
</div>
) : null}
</li>
</a>
</Link>
</>
)
}

Expand Down Expand Up @@ -66,29 +75,41 @@ const MemoedStringWithMatchHighlights = memo(
const indexes = {}

export default function Search() {
const config = useConfig()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [show, setShow] = useState(false)
const [search, setSearch] = useState('')
const [active, setActive] = useState(0)
const [results, setResults] = useState([])
const input = useRef(null)

useEffect(() => {
const doSearch = () => {
if (!search) return

const localeCode = Router.locale || 'default'
const index = indexes[localeCode]

if (!index) return

const pages = {}
const results = []
.concat(
...index
.search(search, { enrich: true, limit: 10, suggest: true })
.map(r => r.result)
)
.map((r, i) => ({ ...r, index: i }))
.sort((a, b) => {
if (a.doc.page !== b.doc.page) return a.doc.page > b.doc.page ? 1 : -1
return a.index - b.index
})
.map(item => {
const firstItemOfPage = !pages[item.doc.page]
pages[item.doc.page] = true

return {
first: firstItemOfPage,
route: item.doc.url,
page: item.doc.page,
title: (
Expand All @@ -108,7 +129,8 @@ export default function Search() {
})

setResults(results)
}, [search])
}
useEffect(doSearch, [search])

const handleKeyDown = useCallback(
e => {
Expand All @@ -118,10 +140,13 @@ export default function Search() {
if (active + 1 < results.length) {
setActive(active + 1)
const activeElement = document.querySelector(
`.nextra-flexsearch ul > :nth-child(${active + 2})`
`.nextra-flexsearch ul > a:nth-of-type(${active + 2})`
)
if (activeElement && activeElement.scrollIntoViewIfNeeded) {
activeElement.scrollIntoViewIfNeeded()
if (activeElement && activeElement.scrollIntoView) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
break
Expand All @@ -131,10 +156,13 @@ export default function Search() {
if (active - 1 >= 0) {
setActive(active - 1)
const activeElement = document.querySelector(
`.nextra-flexsearch ul > :nth-child(${active})`
`.nextra-flexsearch ul > a:nth-of-type(${active})`
)
if (activeElement && activeElement.scrollIntoViewIfNeeded) {
activeElement.scrollIntoViewIfNeeded()
if (activeElement && activeElement.scrollIntoView) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
break
Expand All @@ -150,7 +178,8 @@ export default function Search() {

const load = async () => {
const localeCode = Router.locale || 'default'
if (!indexes[localeCode]) {
if (!indexes[localeCode] && !loading) {
setLoading(true)
const data = await (
await fetch(`/.nextra/data-${localeCode}.json`)
).json()
Expand Down Expand Up @@ -204,6 +233,8 @@ export default function Search() {
}

indexes[localeCode] = index
setLoading(false)
setSearch(s => s + ' ') // Trigger the effect
}
}

Expand Down Expand Up @@ -247,7 +278,13 @@ export default function Search() {
}}
className="block w-full px-3 py-2 leading-tight rounded-lg appearance-none focus:outline-none focus:ring-1 focus:ring-gray-200 focus:bg-white hover:bg-opacity-5 transition-colors dark:focus:bg-dark dark:focus:ring-gray-100 dark:focus:ring-opacity-20"
type="search"
placeholder="Search documentation..."
placeholder={renderComponent(
config.searchPlaceholder,
{
locale: router.locale
},
true
)}
onKeyDown={handleKeyDown}
onFocus={() => {
load()
Expand All @@ -272,22 +309,47 @@ export default function Search() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ul className="absolute z-20 p-0 m-0 mt-2 top-full">
{results.length === 0 ? (
<span className="block p-4 text-center text-gray-400 text-sm select-none">
No results found.
<ul className="absolute z-20 p-0 m-0 mt-2 top-full py-2.5">
{loading ? (
<span className="p-8 text-center text-gray-400 text-sm select-none flex justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Loading...</span>
</span>
) : results.length === 0 ? (
renderComponent(config.unstable_searchResultEmpty, {
locale: router.locale
})
) : (
results.map((res, i) => {
return (
<Item
first={res.first}
key={`search-item-${i}`}
page={res.page}
title={res.title}
href={res.route}
excerpt={res.excerpt}
active={i === active}
onMouseOver={() => setActive(i)}
onHover={() => setActive(i)}
/>
)
})
Expand Down
9 changes: 9 additions & 0 deletions packages/nextra-theme-docs/src/misc/default.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const defaultTheme = {
<meta property="og:description" content="Nextra: the next docs builder" />
<meta name="apple-mobile-web-app-title" content="Nextra" />
</React.Fragment>
),
searchPlaceholder: ({ locale }: { locale?: string }) => {
if (locale === 'zh-CN') return '搜索文档...'
return 'Search documentation...'
},
unstable_searchResultEmpty: () => (
<span className="block p-8 text-center text-gray-400 text-sm select-none">
No results found.
</span>
)
// direction: 'ltr',
// i18n: [{ locale: 'en-US', text: 'English', direction: 'ltr' }],
Expand Down
13 changes: 11 additions & 2 deletions packages/nextra-theme-docs/src/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import type { MouseEventHandler } from 'react'
import type { Item as NormalItem } from './utils/normalize-pages'
import renderComponent from './utils/render-component'
import { useConfig } from './config'
interface ItemProps {
title: string
active: boolean
Expand Down Expand Up @@ -39,6 +41,7 @@ interface SearchProps {

const Search = ({ directories = [] }: SearchProps) => {
const router = useRouter()
const config = useConfig()
const [show, setShow] = useState(false)
const [search, setSearch] = useState('')
const [active, setActive] = useState(0)
Expand Down Expand Up @@ -125,7 +128,13 @@ const Search = ({ directories = [] }: SearchProps) => {
}}
className="block w-full px-3 py-2 leading-tight bg-black bg-opacity-[.03] rounded-lg appearance-none focus:outline-none focus:ring hover:bg-opacity-5 transition-colors"
type="search"
placeholder="Search documentation..."
placeholder={renderComponent(
config.searchPlaceholder,
{
locale: router.locale
},
true
)}
onKeyDown={handleKeyDown}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
Expand All @@ -141,7 +150,7 @@ const Search = ({ directories = [] }: SearchProps) => {
)}
</div>
{renderList && (
<ul className="absolute left-0 z-20 w-full p-0 m-0 mt-1 list-none border divide-y rounded shadow-md md:right-0 top-100 md:w-auto">
<ul className="absolute left-0 z-20 w-full p-0 py-2.5 m-0 mt-1 list-none border divide-y rounded shadow-md md:right-0 top-100 md:w-auto">
{results.map((res, i) => {
return (
<Item
Expand Down
6 changes: 3 additions & 3 deletions packages/nextra-theme-docs/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,16 @@ article {
}
&.nextra-flexsearch ul {
@apply overflow-auto left-0 md:-left-80 md:right-0;
min-height: 120px;
min-height: 100px;
max-height: min(calc(var(--vh) - 12.5rem), 600px);
max-width: min(calc(100vw - 2rem), calc(100% + 20rem));
transition: max-height 0.2s ease;
width: 100vw;
}
ul {
@apply rounded-xl backdrop-blur-lg bg-white bg-opacity-[.7] text-gray-100 ring-1 ring-black ring-opacity-5 py-2.5 overflow-hidden overscroll-contain shadow-xl list-none;
@apply rounded-xl backdrop-blur-lg bg-white bg-opacity-[.7] text-gray-100 ring-1 ring-black ring-opacity-5 overflow-hidden overscroll-contain shadow-xl list-none;
li {
@apply text-gray-800 break-words mx-2.5 p-2.5 rounded-md;
@apply text-gray-800 break-words mx-2.5 px-2.5 py-2 rounded-md;
.highlight {
@apply underline decoration-prime-400 text-prime-500;
}
Expand Down
13 changes: 11 additions & 2 deletions packages/nextra-theme-docs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,22 @@ export interface DocsThemeConfig {
logo?: React.ReactNode
direction?: string
i18n?: { locale: string; text: string; direction: string }[]
unstable_faviconGlyph?: string
customSearch?: boolean
unstable_flexsearch?: boolean
searchPlaceholder?: string | ((props: { locale?: string }) => string)
projectLink?: string
github?: string
projectLinkIcon?: React.FC<{ locale: string }>
projectChatLink?: string
projectChatLinkIcon?: React.FC<{ locale: string }>
floatTOC?: boolean
unstable_faviconGlyph?: string
unstable_flexsearch?: boolean
unstable_searchResultEmpty?:
| React.ReactNode
| React.FC<{
locale: string
config: DocsThemeConfig
title: string
meta: Record<string, any>
}>
}
4 changes: 3 additions & 1 deletion packages/nextra-theme-docs/src/utils/render-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import React from 'react'

const renderComponent = <T,>(
ComponentOrNode: React.FC<T> | React.ReactNode,
props: T
props: T,
functionOnly?: boolean
) => {
if (!ComponentOrNode) return null
if (typeof ComponentOrNode === 'function') {
if (functionOnly) return ComponentOrNode(props)
return <ComponentOrNode {...props} />
}
return ComponentOrNode
Expand Down
12 changes: 10 additions & 2 deletions packages/nextra/src/mdx-plugins/structurize.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import Slugger from 'github-slugger'

function cleanup(content) {
return content
.trim()
.split('\n')
.map(line => line.trim())
.join('\n')
}

export default structurizedData => {
const slugger = new Slugger()
let activeSlug = ''
Expand All @@ -10,7 +18,7 @@ export default structurizedData => {
// @ts-expect-error: assume content model (for root) matches.
return node => {
walk(node)
structurizedData[activeSlug] = content
structurizedData[activeSlug] = cleanup(content)
return node
}

Expand Down Expand Up @@ -66,7 +74,7 @@ export default structurizedData => {
if (type === 'heading') skip = false

if (type === 'heading' && node.depth > 1) {
structurizedData[activeSlug] = content
structurizedData[activeSlug] = cleanup(content)
content = ''
activeSlug = slugger.slug(result) + '#' + result
}
Expand Down

0 comments on commit 839b1c6

Please sign in to comment.