add docs searching

This commit is contained in:
Lee 2024-04-21 02:17:51 +01:00
parent dbed53efe4
commit ebdaf623d9
11 changed files with 234 additions and 31 deletions

@ -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. summary: Welcome to the Minecraft Utilities documentation! Here you can find information on how to use the various features of the API.
--- ---

@ -28,6 +28,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"fuse.js": "^7.0.0",
"lucide-react": "^0.372.0", "lucide-react": "^0.372.0",
"mcutils-library": "^1.2.6", "mcutils-library": "^1.2.6",
"moment": "^2.30.1", "moment": "^2.30.1",

8
pnpm-lock.yaml generated

@ -62,6 +62,9 @@ dependencies:
clsx: clsx:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
fuse.js:
specifier: ^7.0.0
version: 7.0.0
lucide-react: lucide-react:
specifier: ^0.372.0 specifier: ^0.372.0
version: 0.372.0(react@18.2.0) version: 0.372.0(react@18.2.0)
@ -3742,6 +3745,11 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true 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: /gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}

@ -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)));
}

@ -2,19 +2,22 @@ import { CustomMDX } from "@/app/components/mdx-components";
import { Metadata } from "next"; import { Metadata } from "next";
import { generateEmbed } from "@/app/common/embed"; import { generateEmbed } from "@/app/common/embed";
import { Title } from "@/app/components/title"; import { Title } from "@/app/components/title";
import { getDocContent, getDocsContent, getDocsDirectories, getMetadata } from "@/app/common/documentation"; import { getDocContent, getDocsContent } from "@/app/common/documentation";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/app/components/ui/breadcrumb"; } from "@/app/components/ui/breadcrumb";
import { capitalizeFirstLetter } from "@/app/common/string-utils"; import { capitalizeFirstLetter } from "@/app/common/string-utils";
import { notFound } from "next/navigation";
type DocumentationPageParams = { type DocumentationPageParams = {
params: { params: {
/**
* The slug for the documentation page.
*/
slug?: string[]; slug?: string[];
}; };
}; };
@ -49,18 +52,13 @@ export default function Page({ params: { slug } }: DocumentationPageParams) {
// Page was not found, show an error page // Page was not found, show an error page
if (!page) { if (!page) {
return ( return notFound();
<div className="text-center flex flex-col gap-2">
<h1 className="text-red-400 text-2xl">Not Found</h1>
<p>The page you are looking for was not found.</p>
</div>
);
} }
const slugParts = page.slug.split("/"); const slugParts = page.slug.split("/");
return ( return (
<div className="w-full px-4 flex flex-col gap-4"> <div className="w-full h-full px-4 flex flex-col gap-4">
{slugParts.length > 1 && ( {slugParts.length > 1 && (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>

@ -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 (
<div className="w-full flex flex-col items-center gap-2 h-full md:flex-row md:items-start">
<Search />
{children}
</div>
);
}

@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "@/app/components/ui/button";
export default function NotFound() {
return (
<div className="flex text-center flex-col gap-4">
<div>
<h2 className="text-red-400 font-2xl font-semibold">Not Found</h2>
<p>The page you are looking for was not found.</p>
</div>
<Link href="/">
<Button>Return Home</Button>
</Link>
</div>
);
}

@ -1,12 +1,11 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import path from "node:path"; import path from "node:path";
import Fuse from "fuse.js";
const docsDir = path.join(process.cwd(), "documentation");
/** /**
* Metadata for documentation content. * Metadata for documentation content.
*/ */
type DocsContentMetadata = MDXMetadata & { export type DocsContentMetadata = MDXMetadata & {
/** /**
* The title of this content. * The title of this content.
*/ */
@ -50,45 +49,96 @@ type MDXMetadata = {
*/ */
const METADATA_REGEX: RegExp = /---\s*([\s\S]*?)\s*---/; 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<DocsContentMetadata> = new Fuse(cachedDocs, {
keys: ["title", "summary"],
includeScore: true,
threshold: 0.4,
});
/** /**
* Get the directories in the * Get the directories in the
* given directory. * given directory.
*/ */
export function getDocsDirectories(dir: string) { function getDocsDirectories(dir: string): string[] {
const dirs: string[] = [dir]; const directories: string[] = [dir];
const paths = fs.readdirSync(dir); const paths: string[] = fs.readdirSync(dir);
for (const item of paths) { for (const sub of paths) {
const itemPath = path.join(dir, item); const subPath: string = path.join(dir, sub);
const stat = fs.statSync(itemPath); const stat: fs.Stats = fs.statSync(subPath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
dirs.push(...getDocsDirectories(itemPath)); directories.push(...getDocsDirectories(subPath));
} }
} }
return dirs; return directories;
} }
/** /**
* Get the content to * Get the content to
* display in the docs. * display in the docs.
*/ */
export function getDocsContent() { export function getDocsContent(): DocsContentMetadata[] {
const directories = getDocsDirectories(docsDir); const directories: string[] = getDocsDirectories(docsDir);
const content: DocsContentMetadata[] = []; const page: DocsContentMetadata[] = [];
for (let directory of directories) { for (let directory of directories) {
content.push(...getMetadata<DocsContentMetadata>(directory)); page.push(...getMetadata<DocsContentMetadata>(directory));
} }
return content; return page;
} }
export function getDocContent(path?: string[]) { /**
const docs = getDocsContent(); * Get the content of the
const slug = path ? path.join("/") : "landing"; * 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,
};
});
} }
/** /**

@ -12,7 +12,7 @@ export default function Container({ children }: ContainerProps): ReactElement {
return ( return (
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 w-full xs:max-w-[1200px]"> <div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 w-full xs:max-w-[1200px]">
<NavBar /> <NavBar />
<div className="w-full flex mt-4 justify-center">{children}</div> <div className="w-full flex mt-4 justify-center h-full">{children}</div>
</div> </div>
); );
} }

@ -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 && <p>No results found</p>}
{pages &&
pages.length > 1 &&
pages.map(page => {
return (
<DialogClose
key={page.slug}
className="text-left bg-card p-2 rounded-lg"
onClick={() => {
router.replace(`/docs/${page.slug}`);
}}
>
<h2 className="font-semibold">{page.title}</h2>
<p className="text-accent">{page.summary}</p>
</DialogClose>
);
})}
</>
);
}

@ -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<DocsContentMetadata[] | undefined>(undefined);
/**
* Search the documentation
* for the given query.
*
* @param query the query to search for
*/
async function searchDocs(query: string): Promise<void> {
// 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 (
<div className="w-full md:w-[250px] min-h-fit h-fit bg-card rounded-lg p-3">
<div className="flex flex-col w-full">
<Dialog>
<DialogTrigger asChild>
<Button>Search</Button>
</DialogTrigger>
<DialogContent className="w-[600px]">
<DialogTitle>Search Documentation</DialogTitle>
<input
type="text"
placeholder="Search documentation"
className="w-full p-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:border-ring"
onChange={e => searchDocs(e.target.value)}
/>
<DocumentationPages pages={pages} />
</DialogContent>
</Dialog>
</div>
</div>
);
}