Skip to content

Commit ecc6fea

Browse files
authored
Merge pull request #425 from ghostty-org/app-router
App Router
2 parents 2ca86ad + 1a9d6be commit ecc6fea

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1030
-970
lines changed

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Ghostty Website
2+
3+
## Basics
4+
5+
- Verify all changes with `npm run build`
6+
- Lint all code with `npm run lint` and fix all issues including warnings
7+
- The site should be fully static, no server-side rendering, functions, etc.
8+
9+
## Code Style
10+
11+
- All functions and top-level constants should be documented with comments

src/app/HomeContent.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import AnimatedTerminal from "@/components/animated-terminal";
4+
import GridContainer from "@/components/grid-container";
5+
import { ButtonLink } from "@/components/link";
6+
import { P } from "@/components/text";
7+
import type { TerminalFontSize } from "@/components/terminal";
8+
import type { TerminalsMap } from "./terminal-data";
9+
import { useEffect, useState } from "react";
10+
import s from "./home-content.module.css";
11+
12+
interface HomeClientProps {
13+
terminalData: TerminalsMap;
14+
}
15+
16+
/** Tracks the current viewport size for responsive terminal sizing. */
17+
function useWindowSize() {
18+
const [width, setWidth] = useState(0);
19+
const [height, setHeight] = useState(0);
20+
useEffect(() => {
21+
function updateSize() {
22+
setWidth(window.innerWidth);
23+
setHeight(window.innerHeight);
24+
}
25+
window.addEventListener("resize", updateSize);
26+
updateSize();
27+
28+
return () => window.removeEventListener("resize", updateSize);
29+
}, []);
30+
return [width, height];
31+
}
32+
33+
/** Renders the animated terminal hero and action links for the homepage. */
34+
export default function HomeClient({ terminalData }: HomeClientProps) {
35+
const animationFrames = Object.keys(terminalData)
36+
.filter((k) => {
37+
return k.startsWith("home/animation_frames");
38+
})
39+
.map((k) => terminalData[k]);
40+
41+
// Calculate what font size we should use based off of
42+
// Width & Height considerations. We will pick the smaller
43+
// of the two values.
44+
const [windowWidth, windowHeight] = useWindowSize();
45+
const widthSize =
46+
windowWidth > 1100 ? "small" : windowWidth > 674 ? "tiny" : "xtiny";
47+
const heightSize =
48+
windowHeight > 900 ? "small" : windowHeight > 750 ? "tiny" : "xtiny";
49+
let fontSize: TerminalFontSize = "small";
50+
const sizePriority = ["xtiny", "tiny", "small"];
51+
for (const size of sizePriority) {
52+
if (widthSize === size || heightSize === size) {
53+
fontSize = size;
54+
break;
55+
}
56+
}
57+
58+
return (
59+
<main className={s.homePage}>
60+
{/* Don't render the content until the window width has been
61+
calculated, else there will be a flash from the smallest size
62+
of the terminal to the true calculated size */}
63+
{windowWidth > 0 && (
64+
<>
65+
<section className={s.terminalWrapper} aria-hidden={true}>
66+
<AnimatedTerminal
67+
title={"👻 Ghostty"}
68+
fontSize={fontSize}
69+
whitespacePadding={
70+
windowWidth > 950 ? 20 : windowWidth > 850 ? 10 : 0
71+
}
72+
className={s.animatedTerminal}
73+
columns={100}
74+
rows={41}
75+
frames={animationFrames}
76+
frameLengthMs={31}
77+
/>
78+
</section>
79+
80+
<GridContainer>
81+
<P weight="regular" className={s.tagline}>
82+
Ghostty is a fast, feature-rich, and cross-platform terminal
83+
emulator that uses platform-native UI and GPU acceleration.
84+
</P>
85+
</GridContainer>
86+
87+
<GridContainer className={s.buttonsList}>
88+
<ButtonLink href="/download" text="Download" size="large" />
89+
<ButtonLink
90+
href="/docs"
91+
text="Documentation"
92+
size="large"
93+
theme="neutral"
94+
/>
95+
</GridContainer>
96+
</>
97+
)}
98+
</main>
99+
);
100+
}
File renamed without changes.

src/app/docs/DocsPageContent.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Breadcrumbs, { type Breadcrumb } from "@/components/breadcrumbs";
2+
import NavTree, { type NavTreeNode } from "@/components/nav-tree";
3+
import ScrollToTopButton from "@/components/scroll-to-top";
4+
import Sidecar from "@/components/sidecar";
5+
import { H1, P } from "@/components/text";
6+
import { DOCS_PAGES_ROOT_PATH, GITHUB_REPO_URL } from "@/lib/docs/config";
7+
import type { DocsPageData } from "@/lib/docs/page";
8+
import { Pencil } from "lucide-react";
9+
import s from "./DocsPage.module.css";
10+
import customMdxStyles from "@/components/custom-mdx/CustomMDX.module.css";
11+
12+
interface DocsPageContentProps {
13+
navTreeData: NavTreeNode[];
14+
docsPageData: DocsPageData;
15+
breadcrumbs: Breadcrumb[];
16+
}
17+
18+
// DocsPageContent renders the shared docs layout with nav, content, and sidecar.
19+
export default function DocsPageContent({
20+
navTreeData,
21+
docsPageData: {
22+
title,
23+
description,
24+
editOnGithubLink,
25+
content,
26+
relativeFilePath,
27+
pageHeaders,
28+
hideSidecar,
29+
},
30+
breadcrumbs,
31+
}: DocsPageContentProps) {
32+
// Calculate the "Edit in Github" link. If it's not provided
33+
// in the frontmatter, point to the website repo mdx file.
34+
const resolvedEditOnGithubLink = editOnGithubLink
35+
? editOnGithubLink
36+
: `${GITHUB_REPO_URL}/edit/main/${relativeFilePath}`;
37+
38+
return (
39+
<div className={s.docsPage}>
40+
<div className={s.sidebar}>
41+
<div className={s.sidebarContentWrapper}>
42+
<NavTree
43+
nodeGroups={[
44+
{
45+
rootPath: DOCS_PAGES_ROOT_PATH,
46+
nodes: navTreeData,
47+
},
48+
]}
49+
className={s.sidebarNavTree}
50+
/>
51+
</div>
52+
</div>
53+
54+
<main className={s.contentWrapper}>
55+
<ScrollToTopButton />
56+
57+
<div className={s.docsContentWrapper}>
58+
<div className={s.breadcrumbsBar}>
59+
<Breadcrumbs breadcrumbs={breadcrumbs} />
60+
</div>
61+
<div className={s.heading}>
62+
<H1>{title}</H1>
63+
<P className={s.description} weight="regular">
64+
{description}
65+
</P>
66+
</div>
67+
<div className={customMdxStyles.customMDX}>{content}</div>
68+
<br />
69+
<div className={s.editOnGithub}>
70+
<a href={resolvedEditOnGithubLink}>
71+
Edit on GitHub <Pencil size={14} />
72+
</a>
73+
</div>
74+
</div>
75+
76+
<Sidecar
77+
hidden={hideSidecar}
78+
className={s.sidecar}
79+
items={pageHeaders}
80+
/>
81+
</main>
82+
</div>
83+
);
84+
}

src/app/docs/[[...path]]/page.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Metadata } from "next";
2+
import { notFound } from "next/navigation";
3+
import type { Breadcrumb } from "@/components/breadcrumbs";
4+
import type { NavTreeNode } from "@/components/nav-tree";
5+
import { DOCS_DIRECTORY, DOCS_PAGES_ROOT_PATH } from "@/lib/docs/config";
6+
import {
7+
type DocsPageData,
8+
loadAllDocsPageSlugs,
9+
loadDocsPage,
10+
} from "@/lib/docs/page";
11+
import {
12+
docsMetadataTitle,
13+
loadDocsNavTreeData,
14+
navTreeToBreadcrumbs,
15+
} from "@/lib/docs/navigation";
16+
import DocsPageContent from "../DocsPageContent";
17+
18+
interface DocsRouteProps {
19+
params: Promise<{ path?: string[] }>;
20+
}
21+
22+
// Disable runtime fallback routing so unknown docs paths become 404s.
23+
export const dynamicParams = false;
24+
25+
// normalizePathSegments converts an optional catch-all param into a concrete array.
26+
function normalizePathSegments(path: string[] | undefined): string[] {
27+
return path ?? [];
28+
}
29+
30+
// toActivePageSlug maps an optional catch-all route to the docs slug used by loaders.
31+
function toActivePageSlug(path: string[]): string {
32+
return path.length === 0 ? "index" : path.join("/");
33+
}
34+
35+
// isErrorWithCode narrows unknown errors so filesystem codes can be checked safely.
36+
function isErrorWithCode(err: unknown): err is Error & { code: unknown } {
37+
return err instanceof Error && typeof err === "object" && "code" in err;
38+
}
39+
40+
// loadDocsRouteData loads all data needed to render a docs page and its metadata.
41+
async function loadDocsRouteData(path: string[]): Promise<{
42+
navTreeData: NavTreeNode[];
43+
docsPageData: DocsPageData;
44+
breadcrumbs: Breadcrumb[];
45+
}> {
46+
const activePageSlug = toActivePageSlug(path);
47+
const [navTreeData, docsPageData] = await Promise.all([
48+
loadDocsNavTreeData(DOCS_DIRECTORY, activePageSlug),
49+
loadDocsPage(DOCS_DIRECTORY, activePageSlug).catch((err: unknown) => {
50+
if (isErrorWithCode(err) && err.code === "ENOENT") {
51+
notFound();
52+
}
53+
throw err;
54+
}),
55+
]);
56+
57+
const breadcrumbs = navTreeToBreadcrumbs(
58+
"Ghostty Docs",
59+
DOCS_PAGES_ROOT_PATH,
60+
navTreeData,
61+
activePageSlug,
62+
);
63+
64+
return { navTreeData, docsPageData, breadcrumbs };
65+
}
66+
67+
// generateStaticParams pre-renders the docs index and every nested docs slug.
68+
export async function generateStaticParams(): Promise<
69+
Array<{ path: string[] }>
70+
> {
71+
const docsPageSlugs = await loadAllDocsPageSlugs(DOCS_DIRECTORY);
72+
const docsPagePaths = docsPageSlugs
73+
.filter((slug) => slug !== "index")
74+
.map((slug) => ({ path: slug.split("/") }));
75+
76+
return [{ path: [] }, ...docsPagePaths];
77+
}
78+
79+
// generateMetadata builds SEO metadata from the resolved docs page and breadcrumbs.
80+
export async function generateMetadata({
81+
params,
82+
}: DocsRouteProps): Promise<Metadata> {
83+
const { path } = await params;
84+
const { docsPageData, breadcrumbs } = await loadDocsRouteData(
85+
normalizePathSegments(path),
86+
);
87+
88+
return {
89+
title: docsMetadataTitle(breadcrumbs),
90+
description: docsPageData.description,
91+
};
92+
}
93+
94+
// DocsPage renders both /docs and /docs/* routes via a single optional catch-all route.
95+
export default async function DocsPage({ params }: DocsRouteProps) {
96+
const { path } = await params;
97+
const { navTreeData, docsPageData, breadcrumbs } = await loadDocsRouteData(
98+
normalizePathSegments(path),
99+
);
100+
101+
return (
102+
<DocsPageContent
103+
navTreeData={navTreeData}
104+
docsPageData={docsPageData}
105+
breadcrumbs={breadcrumbs}
106+
/>
107+
);
108+
}
File renamed without changes.
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { ButtonLink } from "@/components/link";
22
import GenericCard from "@/components/generic-card";
33
import { CodeXml, Download, Package } from "lucide-react";
44
import s from "./DownloadPage.module.css";
5-
import type { DownloadPageProps } from "./index";
5+
6+
interface ReleaseDownloadPageProps {
7+
latestVersion: string;
8+
}
69

710
export default function ReleaseDownloadPage({
811
latestVersion,
9-
docsNavTree,
10-
}: DownloadPageProps) {
12+
}: ReleaseDownloadPageProps) {
1113
return (
1214
<div className={s.downloadCards}>
1315
<GenericCard
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@ import { ButtonLink } from "@/components/link";
22
import GenericCard from "@/components/generic-card";
33
import { CodeXml, Github, Globe } from "lucide-react";
44
import s from "./DownloadPage.module.css";
5-
import type { DownloadPageProps } from "./index";
65

7-
export default function TipDownloadPage({
8-
latestVersion,
9-
docsNavTree,
10-
}: DownloadPageProps) {
6+
export default function TipDownloadPage() {
117
return (
128
<div className={s.downloadCards}>
139
<GenericCard

0 commit comments

Comments
 (0)