cmux/web/app/[locale]/docs/changelog/page.tsx
Lawrence Chen 46589f531c
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>
2026-03-26 15:10:39 -07:00

339 lines
10 KiB
TypeScript

import fs from "fs";
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). */
function pngDimensions(filePath: string): { width: number; height: number } {
const abs = path.join(process.cwd(), "public", filePath);
const buf = fs.readFileSync(abs);
return {
width: buf.readUInt32BE(16),
height: buf.readUInt32BE(24),
};
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "docs.changelog" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/changelog"),
};
}
interface ChangelogSection {
heading: string;
items: string[];
}
interface ChangelogVersion {
version: string;
date: string;
intro?: string;
sections: ChangelogSection[];
}
function parseChangelog(markdown: string): ChangelogVersion[] {
const versions: ChangelogVersion[] = [];
let current: ChangelogVersion | null = null;
let currentSection: ChangelogSection | null = null;
for (const line of markdown.split("\n")) {
const versionMatch = line.match(/^## \[(.+?)\] - (.+)$/);
if (versionMatch) {
if (current) versions.push(current);
current = {
version: versionMatch[1],
date: versionMatch[2],
sections: [],
};
currentSection = null;
continue;
}
if (!current) continue;
const sectionMatch = line.match(/^### (.+)$/);
if (sectionMatch) {
currentSection = { heading: sectionMatch[1], items: [] };
current.sections.push(currentSection);
continue;
}
const itemMatch = line.match(/^- (.+)$/);
if (itemMatch) {
if (currentSection) {
currentSection.items.push(itemMatch[1]);
} else {
if (!current.sections.length) {
currentSection = { heading: "", items: [] };
current.sections.push(currentSection);
}
current.sections[current.sections.length - 1].items.push(
itemMatch[1]
);
}
continue;
}
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
current.intro = trimmed;
}
}
if (current) versions.push(current);
return versions;
}
function InlineMarkdown({ text }: { text: string }) {
const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^)]+\))/g);
return (
<>
{parts.map((part, i) => {
if (part.startsWith("`") && part.endsWith("`")) {
return <code key={i}>{part.slice(1, -1)}</code>;
}
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
return (
<a key={i} href={linkMatch[2]}>
{linkMatch[1]}
</a>
);
}
return <span key={i}>{part}</span>;
})}
</>
);
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
function HeroImage({ src, version }: { src: string; version: string }) {
const { width, height } = pngDimensions(src);
return (
<div style={{ paddingTop: 16, paddingBottom: 24 }}>
<div className="overflow-hidden rounded-lg">
<Image
src={src}
alt={`cmux ${version}`}
width={width}
height={height}
sizes="(max-width: 640px) 100vw, 640px"
className="w-full h-auto"
priority
/>
</div>
</div>
);
}
function FeatureImage({ src, alt }: { src: string; alt: string }) {
const { width, height } = pngDimensions(src);
return (
<div style={{ paddingTop: 12 }}>
<div className="overflow-hidden rounded-lg">
<Image
src={src}
alt={alt}
width={width}
height={height}
sizes="(max-width: 640px) 100vw, 640px"
className="block w-full max-w-full h-auto"
/>
</div>
</div>
);
}
function FeatureList({ media }: { media: VersionMedia }) {
if (!media.features?.length) return null;
return (
<div style={{ paddingTop: 20, display: "flex", flexDirection: "column", gap: 24 }}>
{media.features.map((feature, i) => (
<div key={i}>
<p style={{ margin: 0, padding: 0 }}>
<strong>{feature.title}.</strong>{" "}
<span className="text-muted">{feature.description}</span>
</p>
{feature.image && (
<FeatureImage src={feature.image} alt={feature.title} />
)}
</div>
))}
</div>
);
}
function ContributorList({ items }: { items: string[] }) {
return (
<div className="flex flex-wrap gap-2" style={{ paddingTop: 8 }}>
{items.map((item, i) => {
const match = item.match(
/\[@([^\]]+)\]\((https:\/\/github\.com\/[^)]+)\)/
);
if (match) {
return (
<a
key={i}
href={match[2]}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border text-[13px] text-muted hover:text-foreground transition-colors no-underline!"
>
<Image
src={`https://github.com/${match[1]}.png?size=48`}
alt={match[1]}
width={18}
height={18}
className="rounded-full"
/>
{match[1]}
</a>
);
}
return (
<span key={i} className="text-[13px] text-muted">
<InlineMarkdown text={item} />
</span>
);
})}
</div>
);
}
function SectionBadge({ heading }: { heading: string }) {
const lower = heading.toLowerCase();
let color = "bg-border/50 text-muted";
let label = heading;
if (lower === "added") {
color = "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
label = "Added";
} else if (lower === "changed") {
color = "bg-blue-500/10 text-blue-600 dark:text-blue-400";
label = "Changed";
} else if (lower === "fixed") {
color = "bg-amber-500/10 text-amber-600 dark:text-amber-400";
label = "Fixed";
} else if (lower.startsWith("thanks")) {
color = "bg-purple-500/10 text-purple-600 dark:text-purple-400";
label = "Contributors";
}
return (
<span
className={`inline-block text-[12px] font-medium px-2 py-0.5 rounded-md ${color}`}
>
{label}
</span>
);
}
export default function ChangelogPage() {
const t = useTranslations("docs.changelog");
const changelogPath = path.join(process.cwd(), "..", "CHANGELOG.md");
const markdown = fs.readFileSync(changelogPath, "utf-8");
const versions = parseChangelog(markdown);
return (
<div className="max-w-[640px] overflow-hidden">
<h1 style={{ margin: 0, padding: 0, paddingBottom: 8 }}>{t("title")}</h1>
<div style={{ paddingTop: 16 }}>
{versions.map((v, vi) => {
const media = changelogMedia[v.version];
return (
<article
key={v.version}
id={`v${v.version}`}
className="border-t border-border first:border-t-0"
style={{ display: "flex", flexDirection: "column", paddingTop: vi === 0 ? 0 : 40, paddingBottom: 40 }}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<a
href={`#v${v.version}`}
className="no-underline! hover:no-underline!"
>
<span className="inline-block text-[13px] font-mono text-muted bg-code-bg px-2 py-0.5 rounded-md">
{v.version}
</span>
</a>
<time
className="text-[13px] text-muted"
dateTime={v.date}
>
{formatDate(v.date)}
</time>
</div>
{media?.title && (
<div style={{ paddingTop: 12, margin: 0, fontSize: "1.5rem", fontWeight: 700, letterSpacing: "-0.025em" }}>
{media.title}
</div>
)}
{media?.hero && (
<HeroImage src={media.hero} version={v.version} />
)}
{media && <FeatureList media={media} />}
{v.intro && !media && (
<div className="text-[14px] text-muted italic" style={{ paddingTop: 12 }}>
{v.intro.replace(/^_/, "").replace(/_$/, "")}
</div>
)}
<div style={{ paddingTop: 20, display: "flex", flexDirection: "column", gap: 16 }}>
{v.sections.map((section, i) => {
const isContributors = section.heading
.toLowerCase()
.startsWith("thanks");
if (isContributors) {
return (
<div key={i}>
<SectionBadge heading={section.heading} />
<ContributorList items={section.items} />
</div>
);
}
return (
<div key={i}>
{section.heading && (
<SectionBadge heading={section.heading} />
)}
<ul style={{ margin: 0, paddingTop: 8, paddingBottom: 0, paddingLeft: 24, listStyle: "disc" }}>
{section.items.map((item, j) => (
<li key={j} style={{ margin: 0, padding: 0, fontSize: 14, lineHeight: 1.6, color: "var(--muted)" }}>
<InlineMarkdown text={item} />
</li>
))}
</ul>
</div>
);
})}
</div>
</article>
);
})}
</div>
</div>
);
}