diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..a537e9c6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,11 @@
+# Ghostty Website
+
+## Basics
+
+- Verify all changes with `npm run build`
+- Lint all code with `npm run lint` and fix all issues including warnings
+- The site should be fully static, no server-side rendering, functions, etc.
+
+## Code Style
+
+- All functions and top-level constants should be documented with comments
diff --git a/src/pages/404Page.module.css b/src/app/404Page.module.css
similarity index 100%
rename from src/pages/404Page.module.css
rename to src/app/404Page.module.css
diff --git a/src/app/HomeContent.tsx b/src/app/HomeContent.tsx
new file mode 100644
index 00000000..69c7ba7d
--- /dev/null
+++ b/src/app/HomeContent.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import AnimatedTerminal from "@/components/animated-terminal";
+import GridContainer from "@/components/grid-container";
+import { ButtonLink } from "@/components/link";
+import { P } from "@/components/text";
+import type { TerminalFontSize } from "@/components/terminal";
+import type { TerminalsMap } from "./terminal-data";
+import { useEffect, useState } from "react";
+import s from "./home-content.module.css";
+
+interface HomeClientProps {
+ terminalData: TerminalsMap;
+}
+
+/** Tracks the current viewport size for responsive terminal sizing. */
+function useWindowSize() {
+ const [width, setWidth] = useState(0);
+ const [height, setHeight] = useState(0);
+ useEffect(() => {
+ function updateSize() {
+ setWidth(window.innerWidth);
+ setHeight(window.innerHeight);
+ }
+ window.addEventListener("resize", updateSize);
+ updateSize();
+
+ return () => window.removeEventListener("resize", updateSize);
+ }, []);
+ return [width, height];
+}
+
+/** Renders the animated terminal hero and action links for the homepage. */
+export default function HomeClient({ terminalData }: HomeClientProps) {
+ const animationFrames = Object.keys(terminalData)
+ .filter((k) => {
+ return k.startsWith("home/animation_frames");
+ })
+ .map((k) => terminalData[k]);
+
+ // Calculate what font size we should use based off of
+ // Width & Height considerations. We will pick the smaller
+ // of the two values.
+ const [windowWidth, windowHeight] = useWindowSize();
+ const widthSize =
+ windowWidth > 1100 ? "small" : windowWidth > 674 ? "tiny" : "xtiny";
+ const heightSize =
+ windowHeight > 900 ? "small" : windowHeight > 750 ? "tiny" : "xtiny";
+ let fontSize: TerminalFontSize = "small";
+ const sizePriority = ["xtiny", "tiny", "small"];
+ for (const size of sizePriority) {
+ if (widthSize === size || heightSize === size) {
+ fontSize = size;
+ break;
+ }
+ }
+
+ return (
+
+ {/* Don't render the content until the window width has been
+ calculated, else there will be a flash from the smallest size
+ of the terminal to the true calculated size */}
+ {windowWidth > 0 && (
+ <>
+
+ 950 ? 20 : windowWidth > 850 ? 10 : 0
+ }
+ className={s.animatedTerminal}
+ columns={100}
+ rows={41}
+ frames={animationFrames}
+ frameLengthMs={31}
+ />
+
+
+
+
+ Ghostty is a fast, feature-rich, and cross-platform terminal
+ emulator that uses platform-native UI and GPU acceleration.
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/pages/docs/[...path]/DocsPage.module.css b/src/app/docs/DocsPage.module.css
similarity index 100%
rename from src/pages/docs/[...path]/DocsPage.module.css
rename to src/app/docs/DocsPage.module.css
diff --git a/src/app/docs/DocsPageContent.tsx b/src/app/docs/DocsPageContent.tsx
new file mode 100644
index 00000000..7ddcbbed
--- /dev/null
+++ b/src/app/docs/DocsPageContent.tsx
@@ -0,0 +1,84 @@
+import Breadcrumbs, { type Breadcrumb } from "@/components/breadcrumbs";
+import NavTree, { type NavTreeNode } from "@/components/nav-tree";
+import ScrollToTopButton from "@/components/scroll-to-top";
+import Sidecar from "@/components/sidecar";
+import { H1, P } from "@/components/text";
+import { DOCS_PAGES_ROOT_PATH, GITHUB_REPO_URL } from "@/lib/docs/config";
+import type { DocsPageData } from "@/lib/docs/page";
+import { Pencil } from "lucide-react";
+import s from "./DocsPage.module.css";
+import customMdxStyles from "@/components/custom-mdx/CustomMDX.module.css";
+
+interface DocsPageContentProps {
+ navTreeData: NavTreeNode[];
+ docsPageData: DocsPageData;
+ breadcrumbs: Breadcrumb[];
+}
+
+// DocsPageContent renders the shared docs layout with nav, content, and sidecar.
+export default function DocsPageContent({
+ navTreeData,
+ docsPageData: {
+ title,
+ description,
+ editOnGithubLink,
+ content,
+ relativeFilePath,
+ pageHeaders,
+ hideSidecar,
+ },
+ breadcrumbs,
+}: DocsPageContentProps) {
+ // Calculate the "Edit in Github" link. If it's not provided
+ // in the frontmatter, point to the website repo mdx file.
+ const resolvedEditOnGithubLink = editOnGithubLink
+ ? editOnGithubLink
+ : `${GITHUB_REPO_URL}/edit/main/${relativeFilePath}`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
{title}
+
+ {description}
+
+
+
{content}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/docs/[[...path]]/page.tsx b/src/app/docs/[[...path]]/page.tsx
new file mode 100644
index 00000000..5ea193e1
--- /dev/null
+++ b/src/app/docs/[[...path]]/page.tsx
@@ -0,0 +1,108 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import type { Breadcrumb } from "@/components/breadcrumbs";
+import type { NavTreeNode } from "@/components/nav-tree";
+import { DOCS_DIRECTORY, DOCS_PAGES_ROOT_PATH } from "@/lib/docs/config";
+import {
+ type DocsPageData,
+ loadAllDocsPageSlugs,
+ loadDocsPage,
+} from "@/lib/docs/page";
+import {
+ docsMetadataTitle,
+ loadDocsNavTreeData,
+ navTreeToBreadcrumbs,
+} from "@/lib/docs/navigation";
+import DocsPageContent from "../DocsPageContent";
+
+interface DocsRouteProps {
+ params: Promise<{ path?: string[] }>;
+}
+
+// Disable runtime fallback routing so unknown docs paths become 404s.
+export const dynamicParams = false;
+
+// normalizePathSegments converts an optional catch-all param into a concrete array.
+function normalizePathSegments(path: string[] | undefined): string[] {
+ return path ?? [];
+}
+
+// toActivePageSlug maps an optional catch-all route to the docs slug used by loaders.
+function toActivePageSlug(path: string[]): string {
+ return path.length === 0 ? "index" : path.join("/");
+}
+
+// isErrorWithCode narrows unknown errors so filesystem codes can be checked safely.
+function isErrorWithCode(err: unknown): err is Error & { code: unknown } {
+ return err instanceof Error && typeof err === "object" && "code" in err;
+}
+
+// loadDocsRouteData loads all data needed to render a docs page and its metadata.
+async function loadDocsRouteData(path: string[]): Promise<{
+ navTreeData: NavTreeNode[];
+ docsPageData: DocsPageData;
+ breadcrumbs: Breadcrumb[];
+}> {
+ const activePageSlug = toActivePageSlug(path);
+ const [navTreeData, docsPageData] = await Promise.all([
+ loadDocsNavTreeData(DOCS_DIRECTORY, activePageSlug),
+ loadDocsPage(DOCS_DIRECTORY, activePageSlug).catch((err: unknown) => {
+ if (isErrorWithCode(err) && err.code === "ENOENT") {
+ notFound();
+ }
+ throw err;
+ }),
+ ]);
+
+ const breadcrumbs = navTreeToBreadcrumbs(
+ "Ghostty Docs",
+ DOCS_PAGES_ROOT_PATH,
+ navTreeData,
+ activePageSlug,
+ );
+
+ return { navTreeData, docsPageData, breadcrumbs };
+}
+
+// generateStaticParams pre-renders the docs index and every nested docs slug.
+export async function generateStaticParams(): Promise<
+ Array<{ path: string[] }>
+> {
+ const docsPageSlugs = await loadAllDocsPageSlugs(DOCS_DIRECTORY);
+ const docsPagePaths = docsPageSlugs
+ .filter((slug) => slug !== "index")
+ .map((slug) => ({ path: slug.split("/") }));
+
+ return [{ path: [] }, ...docsPagePaths];
+}
+
+// generateMetadata builds SEO metadata from the resolved docs page and breadcrumbs.
+export async function generateMetadata({
+ params,
+}: DocsRouteProps): Promise {
+ const { path } = await params;
+ const { docsPageData, breadcrumbs } = await loadDocsRouteData(
+ normalizePathSegments(path),
+ );
+
+ return {
+ title: docsMetadataTitle(breadcrumbs),
+ description: docsPageData.description,
+ };
+}
+
+// DocsPage renders both /docs and /docs/* routes via a single optional catch-all route.
+export default async function DocsPage({ params }: DocsRouteProps) {
+ const { path } = await params;
+ const { navTreeData, docsPageData, breadcrumbs } = await loadDocsRouteData(
+ normalizePathSegments(path),
+ );
+
+ return (
+
+ );
+}
diff --git a/src/pages/download/DownloadPage.module.css b/src/app/download/DownloadPage.module.css
similarity index 100%
rename from src/pages/download/DownloadPage.module.css
rename to src/app/download/DownloadPage.module.css
diff --git a/src/pages/download/release.tsx b/src/app/download/ReleaseDownloadPage.tsx
similarity index 94%
rename from src/pages/download/release.tsx
rename to src/app/download/ReleaseDownloadPage.tsx
index 859cc971..f1016187 100644
--- a/src/pages/download/release.tsx
+++ b/src/app/download/ReleaseDownloadPage.tsx
@@ -2,12 +2,14 @@ import { ButtonLink } from "@/components/link";
import GenericCard from "@/components/generic-card";
import { CodeXml, Download, Package } from "lucide-react";
import s from "./DownloadPage.module.css";
-import type { DownloadPageProps } from "./index";
+
+interface ReleaseDownloadPageProps {
+ latestVersion: string;
+}
export default function ReleaseDownloadPage({
latestVersion,
- docsNavTree,
-}: DownloadPageProps) {
+}: ReleaseDownloadPageProps) {
return (
{
+ const response = await fetch(
+ "https://release.files.ghostty.org/appcast.xml",
+ {
+ cache: "force-cache",
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to fetch XML: ${response.statusText}`);
+ }
+
+ const xmlContent = await response.text();
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ });
+ const parsedXml = parser.parse(xmlContent) as Appcast;
+
+ const items = parsedXml.rss?.channel?.item;
+ if (!items) {
+ throw new Error("Failed to parse appcast XML: no items found");
+ }
+
+ const itemsArray = Array.isArray(items) ? items : [items];
+ const latestItem = itemsArray.reduce((maxItem, currentItem) => {
+ const currentVersion = Number.parseInt(currentItem["sparkle:version"], 10);
+ const maxVersion = Number.parseInt(maxItem["sparkle:version"], 10);
+ return currentVersion > maxVersion ? currentItem : maxItem;
+ });
+
+ return latestItem["sparkle:shortVersionString"];
+}
+
+/** Renders the download page for either stable releases or the tip build. */
+export default async function DownloadPage() {
+ const latestVersion = await fetchLatestGhosttyVersion();
+ const isTip = process.env.GIT_COMMIT_REF === "tip";
+
+ return (
+
+
+
+
+
Download Ghostty
+ {!isTip && (
+
+ Version {latestVersion} -{" "}
+
+ Release Notes
+
+
+ )}
+
+ {isTip ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Home.module.css b/src/app/home-content.module.css
similarity index 100%
rename from src/pages/Home.module.css
rename to src/app/home-content.module.css
diff --git a/src/layouts/root-layout/RootLayout.module.css b/src/app/layout.module.css
similarity index 85%
rename from src/layouts/root-layout/RootLayout.module.css
rename to src/app/layout.module.css
index c5062b17..d01c2385 100644
--- a/src/layouts/root-layout/RootLayout.module.css
+++ b/src/app/layout.module.css
@@ -2,14 +2,14 @@
font-family: var(--pretendard-std-variable);
& a {
- color: var(--gray-9);
+ color: var(--gray-9);
}
& table {
--color-border: var(--gray-2);
--color-header-border: var(--gray-3);
--color-fg: var(--gray-5);
- --color-header-fg:var(--gray-7);
+ --color-header-fg: var(--gray-7);
display: block;
table-layout: auto;
@@ -17,8 +17,9 @@
margin: 32px 0;
overflow: auto;
- & th, td {
- color: var(--color-fg);
+ & th,
+ td {
+ color: var(--color-fg);
text-align: start;
padding: 10px 16px 10px 2px;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 00000000..8b080c19
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,112 @@
+import Footer from "@/components/footer";
+import PathnameFilter from "@/components/pathname-filter";
+import type { SimpleLink } from "@/components/link";
+import Navbar from "@/components/navbar";
+import PreviewBanner from "@/components/preview-banner";
+import { jetbrainsMono, pretendardStdVariable } from "@/components/text";
+import { DOCS_DIRECTORY } from "@/lib/docs/config";
+import { loadDocsNavTreeData } from "@/lib/docs/navigation";
+import "@/styles/globals.css";
+import classNames from "classnames";
+import type { Metadata } from "next";
+import s from "./layout.module.css";
+
+// Navigation links for our nav bars. This currently applies to both
+// the sidebar and footer links equally.
+const navLinks: Array = [
+ {
+ text: "Docs",
+ href: "/docs",
+ },
+ {
+ text: "Discord",
+ href: "https://discord.gg/ghostty",
+ },
+ {
+ text: "GitHub",
+ href: "https://github.com/ghostty-org/ghostty",
+ },
+];
+
+// The paths that don't have the navbar/footer "chrome".
+const NO_CHROME_PATHS = ["/"];
+
+export const metadata: Metadata = {
+ metadataBase: new URL("https://ghostty.org"),
+ title: "Ghostty",
+ description:
+ "Ghostty is a fast, feature-rich, and cross-platform terminal emulator that uses platform-native UI and GPU acceleration.",
+ openGraph: {
+ type: "website",
+ siteName: "Ghostty",
+ url: "https://ghostty.org",
+ images: [
+ {
+ url: "/social-share-card.jpg",
+ width: 1800,
+ height: 3200,
+ },
+ ],
+ },
+ twitter: {
+ images: ["https://ghostty.org/social-share-card.jpg"],
+ },
+ icons: {
+ icon: [
+ { url: "/favicon-32.png", sizes: "32x32", type: "image/png" },
+ { url: "/favicon-16.png", sizes: "16x16", type: "image/png" },
+ ],
+ shortcut: "/favicon.ico",
+ },
+ other: {
+ "darkreader-lock": "",
+ },
+};
+
+export default async function AppLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ // Load the docs tree once at the root so navbar/mobile docs navigation
+ // can be rendered across the site.
+ const docsNavTree = await loadDocsNavTreeData(DOCS_DIRECTORY, "");
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 00000000..797eab04
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,32 @@
+import Image from "next/image";
+import type { Metadata } from "next";
+import { H2, P } from "@/components/text";
+import s from "./404Page.module.css";
+
+export const dynamic = "force-static";
+
+export const metadata: Metadata = {
+ title: "Page not found | Ghostty",
+ description:
+ "Oops! We couldn’t find what you were looking for. Try browsing our docs or visit our download page.",
+};
+
+export default function NotFoundPage() {
+ return (
+
+
+ This page could not be found.
+
+
+
+ CC BY 4.0 (©) Qwerasd
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 00000000..0885fe20
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,16 @@
+import HomeContent from "./HomeContent";
+import { loadAllTerminalFiles } from "./terminal-data";
+import type { Metadata } from "next";
+
+/** Defines document metadata for the Ghostty homepage. */
+export const metadata: Metadata = {
+ title: "Ghostty",
+ description:
+ "Ghostty is a fast, feature-rich, and cross-platform terminal emulator that uses platform-native UI and GPU acceleration.",
+};
+
+/** Loads homepage terminal data and renders the client-side home content. */
+export default async function HomePage() {
+ const terminalData = await loadAllTerminalFiles("/home");
+ return ;
+}
diff --git a/src/lib/fetch-terminal-content.ts b/src/app/terminal-data.tsx
similarity index 81%
rename from src/lib/fetch-terminal-content.ts
rename to src/app/terminal-data.tsx
index ea60c460..c65f32cd 100644
--- a/src/lib/fetch-terminal-content.ts
+++ b/src/app/terminal-data.tsx
@@ -1,11 +1,17 @@
import { promises as fs } from "node:fs";
+
+/** Provides POSIX and path utilities for terminal file traversal. */
const nodePath = require("node:path");
+/** Filesystem location for checked-in terminal snapshot data. */
const TERMINALS_DIRECTORY = "./terminals";
+
+/** Extension used by terminal snapshot files. */
const TERMINAL_CONTENT_FILE_EXTENSION = ".txt";
export type TerminalsMap = { [k: string]: string[] };
+/** Loads terminal text files and returns them keyed by slug path. */
export async function loadAllTerminalFiles(
subdirectory?: string,
): Promise {
@@ -29,6 +35,7 @@ export async function loadAllTerminalFiles(
return Object.fromEntries(map);
}
+/** Walks a directory recursively and returns full file paths. */
async function collectAllFilesRecursively(root: string): Promise {
const files: string[] = [];
const entries = await fs.readdir(root, { withFileTypes: true });
diff --git a/src/components/animated-terminal/index.tsx b/src/components/animated-terminal/index.tsx
index 0e582e52..a2c504a6 100644
--- a/src/components/animated-terminal/index.tsx
+++ b/src/components/animated-terminal/index.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { useEffect, useState } from "react";
import Terminal, { type TerminalProps } from "../terminal";
@@ -76,12 +78,13 @@ export default function AnimatedTerminal({
whitespacePadding,
frameLengthMs,
}: AnimatedTerminalProps) {
+ const baseFps = 1000 / frameLengthMs;
const [currentFrame, setCurrentFrame] = useState(16);
const [animationManager] = useState(
() =>
new AnimationManager(() => {
setCurrentFrame((currentFrame) => (currentFrame + 1) % frames.length);
- }),
+ }, baseFps),
);
useEffect(() => {
@@ -104,10 +107,10 @@ export default function AnimatedTerminal({
if (codeInProgress.length !== KONAMI_CODE.length) {
return;
}
- if (animationManager.frameTime === 1000 / 30) {
+ if (animationManager.frameTime === 1000 / baseFps) {
animationManager.updateFPS(240);
} else {
- animationManager.updateFPS(30);
+ animationManager.updateFPS(baseFps);
}
codeInProgress.length = 0;
};
@@ -123,7 +126,7 @@ export default function AnimatedTerminal({
window.removeEventListener("blur", handleBlur);
window.removeEventListener("keyup", handleKeyUp);
};
- }, [animationManager, frames.length]);
+ }, [animationManager, frames.length, baseFps]);
return (
) : (
- <>{breadcrumb.text}>
+ breadcrumb.text
)}
{i + 1 < breadcrumbs.length && (
diff --git a/src/components/codeblock/index.tsx b/src/components/codeblock/index.tsx
index 2b87a4ba..8c860705 100644
--- a/src/components/codeblock/index.tsx
+++ b/src/components/codeblock/index.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import classNames from "classnames";
import { jetbrainsMono } from "../text";
import s from "./CodeBlock.module.css";
diff --git a/src/components/custom-mdx/index.tsx b/src/components/custom-mdx/index.tsx
deleted file mode 100644
index 6c366ced..00000000
--- a/src/components/custom-mdx/index.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote";
-import { isValidElement, useEffect, type ReactElement } from "react";
-import Blockquote from "../blockquote";
-import ButtonLinks from "../button-links";
-import Callout, { Caution, Important, Note, Tip, Warning } from "../callout";
-import CardLinks from "../card-links";
-import DonateCard from "../donate-card";
-import SponsorCard from "../sponsor-card";
-import CodeBlock from "../codeblock";
-import GitHub from "../github";
-import { processGitHubLinks } from "../github/mdx";
-import JumplinkHeader from "../jumplink-header";
-import Mermaid from "../mermaid";
-import { BodyParagraph, LI } from "../text";
-import VTSequence from "../vt-sequence";
-import Video from "../video";
-import s from "./CustomMDX.module.css";
-import { useStore } from "@/lib/use-store";
-
-interface CustomMDXProps {
- content: MDXRemoteSerializeResult;
-}
-
-type MermaidCodeElement = {
- className?: string;
- children?: string;
-};
-
-function isReactElement(
- children: unknown,
-): children is ReactElement {
- return isValidElement(children);
-}
-
-export default function CustomMDX({ content }: CustomMDXProps) {
- const resetHeaderIdsInView = useStore((state) => state.resetHeaderIdsInView);
-
- useEffect(() => {
- // When we do a client-side navigation to another page
- // the content will change & we will need to do a reset.
- resetHeaderIdsInView();
- }, [content, resetHeaderIdsInView]);
-
- return (
-
-
JumplinkHeader({ ...props, as: "h1" }),
- h2: (props) => JumplinkHeader({ ...props, as: "h2" }),
- h3: (props) => JumplinkHeader({ ...props, as: "h3" }),
- h4: (props) => JumplinkHeader({ ...props, as: "h4" }),
- h5: (props) => JumplinkHeader({ ...props, as: "h5" }),
- h6: (props) => JumplinkHeader({ ...props, as: "h6" }),
- li: (props) => {
- const processedChildren = processGitHubLinks(props.children);
- return {processedChildren};
- },
- p: (props) => {
- const processedChildren = processGitHubLinks(props.children);
- return (
- {processedChildren}
- );
- },
- code: (props) => {
- if (!props.className) {
- return ;
- }
- const language = props.className?.replace("language-", "");
- if (language === "mermaid") {
- return (
-
- );
- }
- return ;
- },
- pre: (props) => {
- const { children, ...rest } = props;
- if (isReactElement(children)) {
- const className = children.props?.className;
- const chart = children.props?.children;
- if (
- typeof chart === "string" &&
- (className === "language-mermaid" ||
- (typeof className === "string" &&
- className.includes("language-mermaid")))
- ) {
- return ;
- }
- }
- return ;
- },
- blockquote: Blockquote,
- img: (props) => (
- // eslint-disable-next-line @next/next/no-img-element
-
- ),
- VTSequence,
- CardLinks,
- ButtonLinks,
- DonateCard,
- SponsorCard,
- GitHub,
- Video,
- /* Callout Variants */
- Callout,
- Note,
- Tip,
- Important,
- Warning,
- Caution,
- "callout-title": () => null,
- Mermaid: (props: {
- chart: string;
- id?: string;
- className?: string;
- }) => ,
- }}
- />
-
- );
-}
diff --git a/src/components/github/index.tsx b/src/components/github/index.tsx
index 9004914e..98420077 100644
--- a/src/components/github/index.tsx
+++ b/src/components/github/index.tsx
@@ -1,4 +1,3 @@
-import { Github } from "lucide-react";
import Link from "../link";
import s from "./GitHub.module.css";
diff --git a/src/components/github/mdx.tsx b/src/components/github/mdx.tsx
index 8657cac5..463a10fe 100644
--- a/src/components/github/mdx.tsx
+++ b/src/components/github/mdx.tsx
@@ -27,7 +27,7 @@ export function processGitHubLinks(children: React.ReactNode): React.ReactNode {
}
if (Array.isArray(children)) {
- return children.map((child, index) => processGitHubLinks(child));
+ return children.map((child) => processGitHubLinks(child));
}
if (isReactElement(children)) {
diff --git a/src/components/jumplink-header/index.tsx b/src/components/jumplink-header/index.tsx
index ff71a24a..d411c782 100644
--- a/src/components/jumplink-header/index.tsx
+++ b/src/components/jumplink-header/index.tsx
@@ -1,16 +1,19 @@
+"use client";
+
import classNames from "classnames";
import { Link } from "lucide-react";
import slugify from "slugify";
import Text from "../text";
import s from "./JumplinkHeader.module.css";
import { useInView } from "react-intersection-observer";
-import { isValidElement, useEffect, useState } from "react";
-import { useStore } from "@/lib/use-store";
+import { isValidElement, useEffect } from "react";
+import { useDocsStore } from "@/lib/docs/store";
interface JumplinkHeaderProps {
as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
className?: string;
children?: React.ReactNode;
+ id?: string;
"data-index"?: string;
}
@@ -18,9 +21,10 @@ export default function JumplinkHeader({
className,
children,
as,
+ id: providedID,
"data-index": dataIndex,
}: JumplinkHeaderProps) {
- const id = headerDeeplinkIdentifier(children, dataIndex);
+ const id = providedID ?? headerDeeplinkIdentifier(children, dataIndex);
const { ref, inView } = useInView({
// This is our header height! This also impacts our
// margin below, but TBH I actually like it needing to
@@ -28,7 +32,9 @@ export default function JumplinkHeader({
rootMargin: "-72px",
threshold: 1,
});
- const updateHeaderIdInView = useStore((state) => state.updateHeaderIdInView);
+ const updateHeaderIdInView = useDocsStore(
+ (state) => state.updateHeaderIdInView,
+ );
useEffect(() => {
updateHeaderIdInView(inView, id);
}, [inView, id, updateHeaderIdInView]);
diff --git a/src/components/nav-tree/index.tsx b/src/components/nav-tree/index.tsx
index 802054f0..73b9c4e3 100644
--- a/src/components/nav-tree/index.tsx
+++ b/src/components/nav-tree/index.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import classNames from "classnames";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
@@ -138,7 +140,7 @@ function Node({ path, node, onLinkNodeClicked, activeItemRef }: NodeProps) {
{
+ onClick={() => {
onLinkNodeClicked?.();
}}
/>
diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx
index 95b99e16..1b94ff10 100644
--- a/src/components/navbar/index.tsx
+++ b/src/components/navbar/index.tsx
@@ -1,4 +1,5 @@
-import { DOCS_PAGES_ROOT_PATH } from "@/pages/docs/[...path]";
+"use client";
+
import classNames from "classnames";
import Image from "next/image";
import NextLink from "next/link";
@@ -11,9 +12,9 @@ import NavTree, {
type LinkNode,
type NavTreeNode,
} from "../nav-tree";
+import { DOCS_PAGES_ROOT_PATH } from "@/lib/docs/config";
import GhosttyWordmark from "./ghostty-wordmark.svg";
import s from "./Navbar.module.css";
-import { useRouter } from "next/router";
export interface NavbarProps {
className?: string;
@@ -31,7 +32,7 @@ export default function Navbar({
docsNavTree,
}: NavbarProps) {
const pathname = usePathname();
- const router = useRouter();
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const mobileContentRef = useRef(null);
const activeItemRef = useRef(null);
@@ -75,19 +76,12 @@ export default function Navbar({
}
}, [mobileMenuOpen]);
- /* Instead of closing the menu with the NavTree's onNavLinkClicked prop,
- * we'll close it when the route changes. This avoids the annoying flicker
- * between the old and new pages when the menu closes. */
+ // Close the mobile menu when navigation changes the pathname.
useEffect(() => {
- const handleRouteChangeComplete = () => {
+ if (mobileMenuOpen) {
setMobileMenuOpen(false);
- };
-
- router.events.on("routeChangeComplete", handleRouteChangeComplete);
- return () => {
- router.events.off("routeChangeComplete", handleRouteChangeComplete);
- };
- }, [router]);
+ }
+ }, [pathname, mobileMenuOpen]);
return (