* 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>
339 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|