From ebdaf623d9960a527f60314e552c387ae19aa2bc Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 21 Apr 2024 02:17:51 +0100 Subject: [PATCH] add docs searching --- documentation/{landing.md => home.md} | 2 +- package.json | 1 + pnpm-lock.yaml | 8 ++ src/app/(pages)/api/docs/search/route.ts | 20 +++++ src/app/(pages)/docs/[[...slug]]/page.tsx | 16 ++-- src/app/(pages)/docs/layout.tsx | 15 ++++ src/app/(pages)/not-found.tsx | 16 ++++ src/app/common/documentation.ts | 90 ++++++++++++++----- src/app/components/container.tsx | 2 +- .../components/docs/documentation-pages.tsx | 40 +++++++++ src/app/components/docs/search.tsx | 55 ++++++++++++ 11 files changed, 234 insertions(+), 31 deletions(-) rename documentation/{landing.md => home.md} (94%) create mode 100644 src/app/(pages)/api/docs/search/route.ts create mode 100644 src/app/(pages)/docs/layout.tsx create mode 100644 src/app/(pages)/not-found.tsx create mode 100644 src/app/components/docs/documentation-pages.tsx create mode 100644 src/app/components/docs/search.tsx diff --git a/documentation/landing.md b/documentation/home.md similarity index 94% rename from documentation/landing.md rename to documentation/home.md index 4980a24..0b5d298 100644 --- a/documentation/landing.md +++ b/documentation/home.md @@ -1,5 +1,5 @@ --- -title: Minecraft Utilities Documentation +title: Home summary: Welcome to the Minecraft Utilities documentation! Here you can find information on how to use the various features of the API. --- diff --git a/package.json b/package.json index ba41ff1..6fd2da1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.0", "clipboard-copy": "^4.0.1", "clsx": "^2.1.0", + "fuse.js": "^7.0.0", "lucide-react": "^0.372.0", "mcutils-library": "^1.2.6", "moment": "^2.30.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0294f7c..2e7ebe4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: clsx: specifier: ^2.1.0 version: 2.1.0 + fuse.js: + specifier: ^7.0.0 + version: 7.0.0 lucide-react: specifier: ^0.372.0 version: 0.372.0(react@18.2.0) @@ -3742,6 +3745,11 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /fuse.js@7.0.0: + resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==} + engines: {node: '>=10'} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} diff --git a/src/app/(pages)/api/docs/search/route.ts b/src/app/(pages)/api/docs/search/route.ts new file mode 100644 index 0000000..1d3f5a0 --- /dev/null +++ b/src/app/(pages)/api/docs/search/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchDocs } from "@/app/common/documentation"; + +export async function GET(request: NextRequest) { + // The query to search for + const query: string | null = request.nextUrl.searchParams.get("query"); + + // No query provided + if (!query) { + return new NextResponse(JSON.stringify({ error: "No query provided" }), { status: 400 }); + } + + // Don't allow queries less than 3 characters + if (query.length < 3) { + return new NextResponse(JSON.stringify({ error: "Query must be at least 3 characters" }), { status: 400 }); + } + + // Return the search results + return new NextResponse(JSON.stringify(searchDocs(query))); +} diff --git a/src/app/(pages)/docs/[[...slug]]/page.tsx b/src/app/(pages)/docs/[[...slug]]/page.tsx index 1449939..2c84f7a 100644 --- a/src/app/(pages)/docs/[[...slug]]/page.tsx +++ b/src/app/(pages)/docs/[[...slug]]/page.tsx @@ -2,19 +2,22 @@ import { CustomMDX } from "@/app/components/mdx-components"; import { Metadata } from "next"; import { generateEmbed } from "@/app/common/embed"; import { Title } from "@/app/components/title"; -import { getDocContent, getDocsContent, getDocsDirectories, getMetadata } from "@/app/common/documentation"; +import { getDocContent, getDocsContent } from "@/app/common/documentation"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, - BreadcrumbPage, BreadcrumbSeparator, } from "@/app/components/ui/breadcrumb"; import { capitalizeFirstLetter } from "@/app/common/string-utils"; +import { notFound } from "next/navigation"; type DocumentationPageParams = { params: { + /** + * The slug for the documentation page. + */ slug?: string[]; }; }; @@ -49,18 +52,13 @@ export default function Page({ params: { slug } }: DocumentationPageParams) { // Page was not found, show an error page if (!page) { - return ( -
-

Not Found

-

The page you are looking for was not found.

-
- ); + return notFound(); } const slugParts = page.slug.split("/"); return ( -
+
{slugParts.length > 1 && ( diff --git a/src/app/(pages)/docs/layout.tsx b/src/app/(pages)/docs/layout.tsx new file mode 100644 index 0000000..c5d042b --- /dev/null +++ b/src/app/(pages)/docs/layout.tsx @@ -0,0 +1,15 @@ +import React, { ReactElement } from "react"; +import { Search } from "@/app/components/docs/search"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>): ReactElement { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/(pages)/not-found.tsx b/src/app/(pages)/not-found.tsx new file mode 100644 index 0000000..bf36d59 --- /dev/null +++ b/src/app/(pages)/not-found.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { Button } from "@/app/components/ui/button"; + +export default function NotFound() { + return ( +
+
+

Not Found

+

The page you are looking for was not found.

+
+ + + +
+ ); +} diff --git a/src/app/common/documentation.ts b/src/app/common/documentation.ts index 33f54ce..66d712a 100644 --- a/src/app/common/documentation.ts +++ b/src/app/common/documentation.ts @@ -1,12 +1,11 @@ import * as fs from "node:fs"; import path from "node:path"; - -const docsDir = path.join(process.cwd(), "documentation"); +import Fuse from "fuse.js"; /** * Metadata for documentation content. */ -type DocsContentMetadata = MDXMetadata & { +export type DocsContentMetadata = MDXMetadata & { /** * The title of this content. */ @@ -50,45 +49,96 @@ type MDXMetadata = { */ const METADATA_REGEX: RegExp = /---\s*([\s\S]*?)\s*---/; +/** + * The directory of the documentation. + */ +const docsDir = path.join(process.cwd(), "documentation"); + +/** + * The cached documentation content. + */ +const cachedDocs: DocsContentMetadata[] = getDocsContent(); + +/** + * The fuse index for searching + */ +const fuseIndex: Fuse = new Fuse(cachedDocs, { + keys: ["title", "summary"], + includeScore: true, + threshold: 0.4, +}); + /** * Get the directories in the * given directory. */ -export function getDocsDirectories(dir: string) { - const dirs: string[] = [dir]; - const paths = fs.readdirSync(dir); +function getDocsDirectories(dir: string): string[] { + const directories: string[] = [dir]; + const paths: string[] = fs.readdirSync(dir); - for (const item of paths) { - const itemPath = path.join(dir, item); - const stat = fs.statSync(itemPath); + for (const sub of paths) { + const subPath: string = path.join(dir, sub); + const stat: fs.Stats = fs.statSync(subPath); if (stat.isDirectory()) { - dirs.push(...getDocsDirectories(itemPath)); + directories.push(...getDocsDirectories(subPath)); } } - return dirs; + return directories; } /** * Get the content to * display in the docs. */ -export function getDocsContent() { - const directories = getDocsDirectories(docsDir); - const content: DocsContentMetadata[] = []; +export function getDocsContent(): DocsContentMetadata[] { + const directories: string[] = getDocsDirectories(docsDir); + const page: DocsContentMetadata[] = []; for (let directory of directories) { - content.push(...getMetadata(directory)); + page.push(...getMetadata(directory)); } - return content; + return page; } -export function getDocContent(path?: string[]) { - const docs = getDocsContent(); - const slug = path ? path.join("/") : "landing"; +/** + * Get the content of the + * documentation page. + * + * @param path the path to the content + */ +export function getDocContent(path?: string[]): DocsContentMetadata | undefined { + const slug: string = path ? path.join("/") : "home"; - return docs.find(doc => doc.slug === slug); + return cachedDocs.find(doc => doc.slug === slug); +} + +/** + * Search the documentation + * for the given query. + * + * @param query the query to search + * @param limit the maximum number of results + */ +export function searchDocs( + query: string, + limit?: number, +): { + title: string; + summary: string; + slug: string; +}[] { + if (!limit) { + limit = 5; // Default to 5 results + } + return fuseIndex.search(query, { limit }).map(result => { + return { + title: result.item.title, + summary: result.item.summary, + slug: result.item.slug, + }; + }); } /** diff --git a/src/app/components/container.tsx b/src/app/components/container.tsx index edb26a2..45564be 100644 --- a/src/app/components/container.tsx +++ b/src/app/components/container.tsx @@ -12,7 +12,7 @@ export default function Container({ children }: ContainerProps): ReactElement { return (
-
{children}
+
{children}
); } diff --git a/src/app/components/docs/documentation-pages.tsx b/src/app/components/docs/documentation-pages.tsx new file mode 100644 index 0000000..96603fa --- /dev/null +++ b/src/app/components/docs/documentation-pages.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { DocsContentMetadata } from "@/app/common/documentation"; +import React, { ReactElement } from "react"; +import { DialogClose } from "../ui/dialog"; +import { useRouter } from "next/navigation"; + +type PagesProps = { + /** + * The documentation pages to display. + */ + pages: DocsContentMetadata[] | undefined; +}; + +export function DocumentationPages({ pages }: PagesProps): ReactElement { + const router = useRouter(); + + return ( + <> + {pages && pages.length === 0 &&

No results found

} + + {pages && + pages.length > 1 && + pages.map(page => { + return ( + { + router.replace(`/docs/${page.slug}`); + }} + > +

{page.title}

+

{page.summary}

+
+ ); + })} + + ); +} diff --git a/src/app/components/docs/search.tsx b/src/app/components/docs/search.tsx new file mode 100644 index 0000000..433abc3 --- /dev/null +++ b/src/app/components/docs/search.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { ReactElement, useState } from "react"; +import { Button } from "@/app/components/ui/button"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/app/components/ui/dialog"; +import { DocsContentMetadata } from "@/app/common/documentation"; +import { DocumentationPages } from "@/app/components/docs/documentation-pages"; + +export function Search(): ReactElement { + /** + * The pages that were found + */ + const [pages, setPages] = useState(undefined); + + /** + * Search the documentation + * for the given query. + * + * @param query the query to search for + */ + async function searchDocs(query: string): Promise { + // Don't bother searching if the query is less than 3 characters + if (query.length < 3) { + return setPages(undefined); + } + + // Attempt to search for the query + const response = await fetch(`/api/docs/search?query=${query}`); + const pages: DocsContentMetadata[] = await response.json(); + setPages(pages); + } + + return ( +
+
+ + + + + + Search Documentation + searchDocs(e.target.value)} + /> + + + + +
+
+ ); +}