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.
|
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
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'}
|
||||||
|
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 { 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>
|
||||||
|
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 * 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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