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}
+
+
+ + Edit on GitHub + +
+
+ +
+
+ ); +} 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} + +