Fix SEO indexing: hreflang, canonicals, sitemap, trailing slash (#2193)
* 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>
This commit is contained in:
parent
f507fd8141
commit
46589f531c
28 changed files with 113 additions and 36 deletions
|
|
@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
|||
export const metadata: Metadata = {
|
||||
title: "EULA — cmux",
|
||||
description: "End-User License Agreement for cmux",
|
||||
alternates: { canonical: "./" },
|
||||
alternates: { canonical: "https://cmux.com/eula" },
|
||||
};
|
||||
|
||||
export default function EulaPage() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Link } from "../../../../i18n/navigation";
|
|||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy — cmux",
|
||||
description: "Privacy policy for cmux",
|
||||
alternates: { canonical: "./" },
|
||||
alternates: { canonical: "https://cmux.com/privacy-policy" },
|
||||
};
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
|||
export const metadata: Metadata = {
|
||||
title: "Terms of Service — cmux",
|
||||
description: "Terms of service for cmux",
|
||||
alternates: { canonical: "./" },
|
||||
alternates: { canonical: "https://cmux.com/terms-of-service" },
|
||||
};
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { Link } from "../../../../i18n/navigation";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "blog.cmdShiftU" });
|
||||
const url = locale === "en" ? "/blog/cmd-shift-u" : `/${locale}/blog/cmd-shift-u`;
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
|
|
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
description: t("metaDescription"),
|
||||
type: "article",
|
||||
publishedTime: "2026-03-04T00:00:00Z",
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
},
|
||||
alternates: { canonical: url },
|
||||
alternates: buildAlternates(locale, "/blog/cmd-shift-u"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { Link } from "../../../../i18n/navigation";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "blog.introducingCmux" });
|
||||
const url = locale === "en" ? "/blog/introducing-cmux" : `/${locale}/blog/introducing-cmux`;
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
|
|
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
description: t("metaDescription"),
|
||||
type: "article",
|
||||
publishedTime: "2026-02-12T00:00:00Z",
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
},
|
||||
alternates: { canonical: url },
|
||||
alternates: buildAlternates(locale, "/blog/introducing-cmux"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
import { BlogPager } from "../components/blog-pager";
|
||||
import { BlogCTA } from "../components/blog-cta";
|
||||
|
|
@ -19,9 +20,7 @@ export async function generateMetadata({
|
|||
siteName: "cmux",
|
||||
type: "article" as const,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "./",
|
||||
},
|
||||
alternates: buildAlternates(locale, "/blog"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { Link } from "../../../i18n/navigation";
|
||||
|
||||
export async function generateMetadata({
|
||||
|
|
@ -12,6 +13,7 @@ export async function generateMetadata({
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/blog"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { Link } from "../../../../i18n/navigation";
|
||||
import { Tweet } from "react-tweet";
|
||||
import starHistory from "./star-history.png";
|
||||
|
|
@ -8,7 +9,6 @@ import starHistory from "./star-history.png";
|
|||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "blog.showHnLaunch" });
|
||||
const url = locale === "en" ? "/blog/show-hn-launch" : `/${locale}/blog/show-hn-launch`;
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
|
|
@ -22,14 +22,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
description: t("metaDescription"),
|
||||
type: "article",
|
||||
publishedTime: "2026-02-21T00:00:00Z",
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
},
|
||||
alternates: { canonical: url },
|
||||
alternates: buildAlternates(locale, "/blog/show-hn-launch"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { Link } from "../../../../i18n/navigation";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "blog.zenOfCmux" });
|
||||
const url = locale === "en" ? "/blog/zen-of-cmux" : `/${locale}/blog/zen-of-cmux`;
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
|
|
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
description: t("metaDescription"),
|
||||
type: "article",
|
||||
publishedTime: "2026-02-27T00:00:00Z",
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
},
|
||||
alternates: { canonical: url },
|
||||
alternates: buildAlternates(locale, "/blog/zen-of-cmux"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
|
@ -8,7 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: { canonical: "./" },
|
||||
alternates: buildAlternates(locale, "/community"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
import { Callout } from "../../components/callout";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/api"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
|
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/browser-automation"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "path";
|
|||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { changelogMedia, type VersionMedia } from "./changelog-media";
|
||||
|
||||
/** Read PNG dimensions from the IHDR chunk (bytes 16-23). */
|
||||
|
|
@ -21,6 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/changelog"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
|
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/concepts"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
import { Callout } from "../../components/callout";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/configuration"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
import { Callout } from "../../components/callout";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/custom-commands"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
import { Callout } from "../../components/callout";
|
||||
import { DownloadButton } from "../../components/download-button";
|
||||
|
|
@ -10,6 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/getting-started"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { KeyboardShortcuts } from "../../keyboard-shortcuts";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
|
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/keyboard-shortcuts"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { DocsNav } from "./docs-nav";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
|
||||
|
|
@ -18,9 +19,7 @@ export async function generateMetadata({
|
|||
siteName: "cmux",
|
||||
type: "article" as const,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "./",
|
||||
},
|
||||
alternates: buildAlternates(locale, "/docs"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../../i18n/seo";
|
||||
import { CodeBlock } from "../../components/code-block";
|
||||
import { Callout } from "../../components/callout";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: buildAlternates(locale, "/docs/notifications"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} 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";
|
||||
|
|
@ -30,8 +31,7 @@ export async function generateMetadata({
|
|||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "meta" });
|
||||
const url =
|
||||
locale === "en" ? "https://cmux.com" : `https://cmux.com/${locale}`;
|
||||
const alternates = buildAlternates(locale, "");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
|
|
@ -52,7 +52,7 @@ export async function generateMetadata({
|
|||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("ogDescription"),
|
||||
url,
|
||||
url: alternates.canonical,
|
||||
siteName: "cmux",
|
||||
type: "website",
|
||||
},
|
||||
|
|
@ -61,6 +61,7 @@ export async function generateMetadata({
|
|||
title: t("title"),
|
||||
description: t("ogDescription"),
|
||||
},
|
||||
alternates,
|
||||
metadataBase: new URL("https://cmux.com"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
|
||||
export async function generateMetadata({
|
||||
|
|
@ -12,7 +13,7 @@ export async function generateMetadata({
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: { canonical: "./" },
|
||||
alternates: buildAlternates(locale, "/nightly"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildAlternates } from "../../../i18n/seo";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
import { testimonials, TestimonialCard, getTestimonialTranslation } from "../testimonials";
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
alternates: { canonical: "./" },
|
||||
alternates: buildAlternates(locale, "/wall-of-love"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
|||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
rules: { userAgent: "*", allow: "/", disallow: "/_next/" },
|
||||
sitemap: "https://cmux.com/sitemap.xml",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
{ path: "/docs/getting-started", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.9 },
|
||||
{ path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
{ path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
{ path: "/docs/custom-commands", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
|
||||
{ path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
|
||||
{ path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
{ path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
|
|
@ -27,9 +28,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
{ path: "/eula", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
|
||||
];
|
||||
|
||||
// Legal pages are English-only (not translated), so they only get one entry.
|
||||
const englishOnly = new Set(["/privacy-policy", "/terms-of-service", "/eula"]);
|
||||
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const { path, lastModified, changeFrequency, priority } of paths) {
|
||||
if (englishOnly.has(path)) {
|
||||
entries.push({
|
||||
url: `${base}${path}`,
|
||||
lastModified,
|
||||
changeFrequency,
|
||||
priority,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const alternates: Record<string, string> = {};
|
||||
for (const locale of locales) {
|
||||
alternates[locale] =
|
||||
|
|
@ -37,14 +51,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
}
|
||||
alternates["x-default"] = `${base}${path}`;
|
||||
|
||||
// Emit a separate entry for each locale so Google sees every URL declared
|
||||
for (const locale of locales) {
|
||||
const url =
|
||||
locale === "en" ? `${base}${path}` : `${base}/${locale}${path}`;
|
||||
entries.push({
|
||||
url: `${base}${path}`,
|
||||
url,
|
||||
lastModified,
|
||||
changeFrequency,
|
||||
priority,
|
||||
alternates: { languages: alternates },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
|
|
|||
22
web/i18n/seo.ts
Normal file
22
web/i18n/seo.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { locales } from "./routing";
|
||||
|
||||
const BASE = "https://cmux.com";
|
||||
|
||||
/**
|
||||
* Build the full alternates object (canonical + hreflang languages)
|
||||
* for a given locale and path. Use in every generateMetadata that
|
||||
* sets alternates so child metadata doesn't wipe parent hreflang.
|
||||
*/
|
||||
export function buildAlternates(locale: string, path: string) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const loc of locales) {
|
||||
languages[loc] =
|
||||
loc === "en" ? `${BASE}${path}` : `${BASE}/${loc}${path}`;
|
||||
}
|
||||
languages["x-default"] = `${BASE}${path}`;
|
||||
|
||||
const canonical =
|
||||
locale === "en" ? `${BASE}${path}` : `${BASE}/${locale}${path}`;
|
||||
|
||||
return { canonical, languages };
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
skipTrailingSlashRedirect: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
|
|
|||
19
web/proxy.ts
19
web/proxy.ts
|
|
@ -15,6 +15,25 @@ export default function middleware(request: NextRequest) {
|
|||
return NextResponse.redirect(url.toString(), 301);
|
||||
}
|
||||
|
||||
// Legal pages are English-only. Redirect /<locale>/legal-page to /legal-page,
|
||||
// and skip next-intl for /legal-page so locale detection can't redirect back.
|
||||
const legalPages = new Set(["/privacy-policy", "/terms-of-service", "/eula"]);
|
||||
const { pathname } = request.nextUrl;
|
||||
if (legalPages.has(pathname)) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/en${pathname}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
const secondSlash = pathname.indexOf("/", 1);
|
||||
if (secondSlash !== -1) {
|
||||
const rest = pathname.slice(secondSlash);
|
||||
if (legalPages.has(rest)) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = rest;
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue