* Fix SEO indexing: add hreflang, canonicals, sitemap per-locale entries Google Search Console showed 380 not-indexed vs 86 indexed pages. Root causes: missing hreflang tags on rendered pages (only in sitemap), no canonical on homepage, inconsistent canonicals wiping parent hreflang, sitemap only listing English URLs, trailing slash duplicates, and _next/static chunks being crawled as pages. Changes: - Add buildAlternates() utility for consistent canonical + hreflang - Add hreflang tags to all pages via alternates.languages in metadata - Add self-referencing canonical URLs to every page (homepage had none) - Expand sitemap to emit separate entries for each locale - Add missing /docs/custom-commands to sitemap - Remove skipTrailingSlashRedirect to normalize trailing slashes - Block /_next/ in robots.txt to stop chunk crawling * Add per-page alternates to docs sub-pages and blog index Docs sub-pages and blog index only returned title/description in generateMetadata, so they inherited the parent layout's alternates (pointing to /docs or /blog). Now each page sets its own buildAlternates() with the correct path so canonical and hreflang point to the actual page URL. * Derive openGraph.url from buildAlternates to avoid drift * Redirect non-English legal pages to English, remove from sitemap Legal pages (privacy policy, TOS, EULA) are untranslated English content. Serving them under every locale creates 54 duplicate URLs. Now: - Middleware 301-redirects /ja/privacy-policy etc. to /privacy-policy - Sitemap only includes English URLs for legal pages (no locale variants) - Legal page metadata uses static English-only canonical * Fix legal page redirect to only match /<locale>/<page> paths endsWith matched too broadly (e.g. /docs/eula). Now only redirects when the path after the first segment is an exact legal page match. * Skip next-intl for legal pages to prevent locale redirect loop Without this, a Japanese user hitting /privacy-policy could be redirected by next-intl to /ja/privacy-policy, which our middleware redirects back to /privacy-policy, creating a loop. * Rewrite legal pages to /en/ instead of NextResponse.next() Pages live under app/[locale]/, so skipping next-intl entirely would break route resolution. Rewrite to /en/privacy-policy etc. so Next.js can resolve the [locale] segment correctly. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
135 lines
3.9 KiB
TypeScript
135 lines
3.9 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { Geist, Geist_Mono } from "next/font/google";
|
|
import { NextIntlClientProvider } from "next-intl";
|
|
import {
|
|
getMessages,
|
|
getTranslations,
|
|
setRequestLocale,
|
|
} from "next-intl/server";
|
|
import { notFound } from "next/navigation";
|
|
import { routing } from "../../i18n/routing";
|
|
import { buildAlternates } from "../../i18n/seo";
|
|
import { Providers } from "./providers";
|
|
import { DevPanel } from "./components/spacing-control";
|
|
import { SiteFooter } from "./components/site-footer";
|
|
import "../globals.css";
|
|
|
|
const geistSans = Geist({
|
|
variable: "--font-geist-sans",
|
|
subsets: ["latin"],
|
|
});
|
|
|
|
const geistMono = Geist_Mono({
|
|
variable: "--font-geist-mono",
|
|
subsets: ["latin"],
|
|
});
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ locale: string }>;
|
|
}): Promise<Metadata> {
|
|
const { locale } = await params;
|
|
const t = await getTranslations({ locale, namespace: "meta" });
|
|
const alternates = buildAlternates(locale, "");
|
|
return {
|
|
title: t("title"),
|
|
description: t("description"),
|
|
keywords: [
|
|
"terminal",
|
|
"macOS",
|
|
"coding agents",
|
|
"Claude Code",
|
|
"Codex",
|
|
"OpenCode",
|
|
"Gemini CLI",
|
|
"Kiro",
|
|
"Aider",
|
|
"Ghostty",
|
|
"AI",
|
|
"terminal for AI agents",
|
|
],
|
|
openGraph: {
|
|
title: t("title"),
|
|
description: t("ogDescription"),
|
|
url: alternates.canonical,
|
|
siteName: "cmux",
|
|
type: "website",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: t("title"),
|
|
description: t("ogDescription"),
|
|
},
|
|
alternates,
|
|
metadataBase: new URL("https://cmux.com"),
|
|
};
|
|
}
|
|
|
|
export function generateStaticParams() {
|
|
return routing.locales.map((locale) => ({ locale }));
|
|
}
|
|
|
|
export default async function LocaleLayout({
|
|
children,
|
|
params,
|
|
}: {
|
|
children: React.ReactNode;
|
|
params: Promise<{ locale: string }>;
|
|
}) {
|
|
const { locale } = await params;
|
|
|
|
if (!routing.locales.includes(locale as typeof routing.locales[number])) {
|
|
notFound();
|
|
}
|
|
|
|
setRequestLocale(locale);
|
|
|
|
const messages = await getMessages();
|
|
|
|
const dir = locale === "ar" ? "rtl" : "ltr";
|
|
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "SoftwareApplication",
|
|
name: "cmux",
|
|
operatingSystem: "macOS",
|
|
applicationCategory: "DeveloperApplication",
|
|
url: "https://cmux.com",
|
|
downloadUrl:
|
|
"https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg",
|
|
description:
|
|
"Native macOS terminal built on Ghostty. Works with Claude Code, Codex, OpenCode, Gemini CLI, Kiro, Aider, and any CLI tool. Vertical tabs, notification rings, split panes, and a socket API.",
|
|
keywords:
|
|
"terminal, macOS, Claude Code, Codex, OpenCode, Gemini CLI, Kiro, Aider, AI coding agents, Ghostty",
|
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
|
};
|
|
|
|
return (
|
|
<html lang={locale} dir={dir} suppressHydrationWarning>
|
|
<head>
|
|
<meta name="theme-color" content="#0a0a0a" />
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `(function(){try{var t=localStorage.getItem("theme");var light=t==="light"||(t==="system"&&window.matchMedia("(prefers-color-scheme:light)").matches);if(!light)document.documentElement.classList.add("dark");var m=document.querySelector('meta[name="theme-color"]');if(m)m.content=light?"#fafafa":"#0a0a0a"}catch(e){}})()`,
|
|
}}
|
|
/>
|
|
</head>
|
|
<body
|
|
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
|
>
|
|
<NextIntlClientProvider messages={messages}>
|
|
<Providers>
|
|
{children}
|
|
<SiteFooter />
|
|
<DevPanel />
|
|
</Providers>
|
|
</NextIntlClientProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|