add docs searching
This commit is contained in:
parent
dbed53efe4
commit
ebdaf623d9
@ -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.
|
||||
---
|
||||
|
@ -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",
|
||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -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'}
|
||||
|
20
src/app/(pages)/api/docs/search/route.ts
Normal file
20
src/app/(pages)/api/docs/search/route.ts
Normal file
@ -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 { 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 (
|
||||
<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>
|
||||
);
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const slugParts = page.slug.split("/");
|
||||
|
||||
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 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
|
15
src/app/(pages)/docs/layout.tsx
Normal file
15
src/app/(pages)/docs/layout.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
16
src/app/(pages)/not-found.tsx
Normal file
16
src/app/(pages)/not-found.tsx
Normal file
@ -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 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<DocsContentMetadata> = 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<DocsContentMetadata>(directory));
|
||||
page.push(...getMetadata<DocsContentMetadata>(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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -12,7 +12,7 @@ export default function Container({ children }: ContainerProps): ReactElement {
|
||||
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]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
40
src/app/components/docs/documentation-pages.tsx
Normal file
40
src/app/components/docs/documentation-pages.tsx
Normal file
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
55
src/app/components/docs/search.tsx
Normal file
55
src/app/components/docs/search.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user