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 (
+
+
+
+
+
+ );
+}