diff --git a/apps/docs/public/_headers b/apps/docs/public/_headers new file mode 100644 index 00000000..918b49e0 --- /dev/null +++ b/apps/docs/public/_headers @@ -0,0 +1,8 @@ +/assets/* + Cache-Control: public, max-age=31556952, immutable + +/robots.txt + Cache-Control: public, max-age=3600 + +/sitemap.xml + Cache-Control: public, max-age=3600 diff --git a/apps/docs/src/assets/og.jpg b/apps/docs/src/assets/og.jpg new file mode 100644 index 00000000..d3d54bb3 Binary files /dev/null and b/apps/docs/src/assets/og.jpg differ diff --git a/apps/docs/src/assets/screenshot-1440.webp b/apps/docs/src/assets/screenshot-1440.webp new file mode 100644 index 00000000..a5aa45e9 Binary files /dev/null and b/apps/docs/src/assets/screenshot-1440.webp differ diff --git a/apps/docs/src/assets/screenshot-768.webp b/apps/docs/src/assets/screenshot-768.webp new file mode 100644 index 00000000..defe37d9 Binary files /dev/null and b/apps/docs/src/assets/screenshot-768.webp differ diff --git a/apps/docs/src/components/LandingPage.tsx b/apps/docs/src/components/LandingPage.tsx index 333f7bba..26f670b2 100644 --- a/apps/docs/src/components/LandingPage.tsx +++ b/apps/docs/src/components/LandingPage.tsx @@ -23,6 +23,8 @@ import { type LucideIcon, } from "lucide-react"; import { GithubLogoIcon } from "@phosphor-icons/react"; +import screenshot1440Url from "@/assets/screenshot-1440.webp"; +import screenshot768Url from "@/assets/screenshot-768.webp"; import { CornerCard } from "./CornerCard"; import Footer from "./Footer"; @@ -208,8 +210,14 @@ function BrowserMockup() {
Zerobyte backups dashboard
diff --git a/apps/docs/src/lib/metadata.ts b/apps/docs/src/lib/metadata.ts new file mode 100644 index 00000000..d13536f7 --- /dev/null +++ b/apps/docs/src/lib/metadata.ts @@ -0,0 +1,34 @@ +import ogImageAssetUrl from "@/assets/og.jpg"; + +export const siteUrl = "https://zerobyte.app"; +export const siteTitle = "Zerobyte | Backup automation for Restic"; +export const siteDescription = + "Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows."; +export const ogImageUrl = new URL(ogImageAssetUrl, siteUrl).toString(); + +export function getCanonicalUrl(path: string) { + return new URL(path, siteUrl).toString(); +} + +export function buildSeoHead({ title, description, path }: { title: string; description: string; path: string }) { + const canonicalUrl = getCanonicalUrl(path); + + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: canonicalUrl }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ], + links: [{ rel: "canonical", href: canonicalUrl }], + }; +} + +export function formatDocsTitle(title: string) { + if (title.toLowerCase().includes("zerobyte")) return title; + + return `${title} | Zerobyte Docs`; +} diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx index a7b2a766..cd25fb19 100644 --- a/apps/docs/src/routes/__root.tsx +++ b/apps/docs/src/routes/__root.tsx @@ -2,12 +2,10 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; import { RootProvider } from "fumadocs-ui/provider/tanstack"; import Header from "../components/Header"; +import { ogImageUrl } from "@/lib/metadata"; import appCss from "../styles.css?url"; -const siteUrl = "https://zerobyte.app"; -const ogImageUrl = `${siteUrl}/images/og.png`; - export const Route = createRootRoute({ head: () => ({ meta: [ @@ -18,31 +16,10 @@ export const Route = createRootRoute({ name: "viewport", content: "width=device-width, initial-scale=1", }, - { - title: "Zerobyte | Backup automation for Restic", - }, - { - name: "description", - content: - "Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.", - }, - { - property: "og:title", - content: "Zerobyte | Backup automation for Restic", - }, - { - property: "og:description", - content: - "Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.", - }, { property: "og:type", content: "website", }, - { - property: "og:url", - content: siteUrl, - }, { property: "og:image", content: ogImageUrl, @@ -63,15 +40,6 @@ export const Route = createRootRoute({ name: "twitter:card", content: "summary_large_image", }, - { - name: "twitter:title", - content: "Zerobyte | Backup automation for Restic", - }, - { - name: "twitter:description", - content: - "Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.", - }, { name: "twitter:image", content: ogImageUrl, diff --git a/apps/docs/src/routes/docs/$.tsx b/apps/docs/src/routes/docs/$.tsx index 5a17bebe..65fdc3a4 100644 --- a/apps/docs/src/routes/docs/$.tsx +++ b/apps/docs/src/routes/docs/$.tsx @@ -12,12 +12,30 @@ import { useFumadocsLoader } from "fumadocs-core/source/client"; import { Suspense } from "react"; import { Card, Cards } from "@/components/DocsCard"; import { DocsMdxLink } from "@/components/DocsMdxLink"; +import { buildSeoHead, formatDocsTitle, siteDescription } from "@/lib/metadata"; + +type DocsLoaderData = { + path: string; + title: string; + description: string; + url: string; + pageTree: Awaited>; +}; export const Route = createFileRoute("/docs/$")({ + head: ({ loaderData }) => { + const data = loaderData as DocsLoaderData | undefined; + const title = formatDocsTitle(data?.title ?? "Zerobyte Documentation"); + const description = data?.description ?? siteDescription; + const path = data?.url ?? "/docs"; + + return buildSeoHead({ title, description, path }); + }, component: Page, loader: async ({ params }) => { const slugs = params._splat?.split("/") ?? []; const data = await serverLoader({ data: slugs }); + if (!data) throw notFound(); await clientLoader.preload(data.path); return data; }, @@ -32,6 +50,9 @@ const serverLoader = createServerFn({ if (!page) throw notFound(); return { path: page.path, + title: page.data.title ?? "Zerobyte Documentation", + description: page.data.description ?? siteDescription, + url: page.url, pageTree: await source.serializePageTree(source.getPageTree()), }; }); @@ -77,7 +98,8 @@ const clientLoader = browserCollections.docs.createClientLoader({ }, }); function Page() { - const data = useFumadocsLoader(Route.useLoaderData()); + const data = useFumadocsLoader(Route.useLoaderData() as DocsLoaderData); + return (
diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx index fc70ca48..4f8e1a1d 100644 --- a/apps/docs/src/routes/index.tsx +++ b/apps/docs/src/routes/index.tsx @@ -1,8 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import LandingPage from "../components/LandingPage"; +import { buildSeoHead, siteDescription, siteTitle } from "@/lib/metadata"; -export const Route = createFileRoute("/")({ component: App }); +export const Route = createFileRoute("/")({ + head: () => buildSeoHead({ title: siteTitle, description: siteDescription, path: "/" }), + component: App, +}); function App() { return ; diff --git a/apps/docs/src/routes/sitemap[.]xml.ts b/apps/docs/src/routes/sitemap[.]xml.ts index 63e42e8f..cd58e445 100644 --- a/apps/docs/src/routes/sitemap[.]xml.ts +++ b/apps/docs/src/routes/sitemap[.]xml.ts @@ -1,9 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; +import { siteUrl } from "@/lib/metadata"; import { source } from "@/lib/source"; -const siteUrl = "https://zerobyte.app"; - function escapeXml(value: string) { return value .replaceAll("&", "&") diff --git a/apps/docs/wrangler.jsonc b/apps/docs/wrangler.jsonc index 287dbbef..c78544a0 100644 --- a/apps/docs/wrangler.jsonc +++ b/apps/docs/wrangler.jsonc @@ -4,4 +4,7 @@ "compatibility_date": "2025-09-02", "compatibility_flags": ["nodejs_compat"], "main": "@tanstack/react-start/server-entry", + "assets": { + "run_worker_first": ["/sitemap.xml"], + }, }