* Add i18n framework with next-intl for 19 languages
Set up complete internationalization infrastructure:
- Install next-intl v4 with App Router support
- Create i18n config (routing, request, navigation)
- Add middleware for automatic locale detection from Accept-Language
- Restructure all routes under app/[locale]/
- Extract UI strings to messages/en.json
- Update all components to use useTranslations()
- Add language switcher dropdown in footer
- Support RTL for Arabic and Khmer
- Update sitemap with locale alternates
- Add generateStaticParams for all 19 locales
Languages: en, ja, zh-CN, zh-TW, ko, de, es, fr, it, da, pl, ru, bs, ar, no, pt-BR, th, tr, km
Locale detection: auto-detect from browser Accept-Language header,
with cookie persistence and locale prefix only for non-default (en).
* Add translations for de, fr, it, ja, zh-CN, zh-TW
* Add translations for ar, bs, da, es, km, no, pl, pt-BR, ru, th, tr
* Convert docs and legal pages to use useTranslations()
* Add i18n to keyboard shortcuts component
* Add i18n to wall-of-love, add missing blog posts to sitemap
* Add keyboard shortcuts and wallOfLove translations to all locales
* Update bun lockfile for next-intl dependency
* Fix t.rich() configPath: pass ReactNode not function for {var} interpolation
* Fix configPath: use rich text tag instead of plain interpolation for ReactNode
* Fix t.rich() interpolation: use rich text tags for all ReactNode placeholders
Changed {legacy}, {openShortcut}, {jumpShortcut} from plain variable
interpolation to <tag>content</tag> format so t.rich() gets proper
functions instead of values.
* Escape ICU curly braces in socketCallout rich text across all locales
* Fix i18n issues: Khmer RTL, zh-CN quality, locale-aware testimonials, hardcoded strings
- Fix Khmer (km) incorrectly marked as RTL (it's LTR, only Arabic is RTL)
- Fix zh-CN/zh-TW taglinePrefix to mention terminals and open source
- Add locale-aware testimonial translations: show original text, translate
for non-matching locales, skip translation when locale matches original
- Translate hardcoded English table content in notifications page
- Add testimonial translations to all 19 locale files
- Remove unused setRequestLocale import and params from home page
* Address PR review comments: metadata localization, blog fixes, legal pages, accessibility
- Convert hardcoded metadata to generateMetadata with getTranslations on all docs, blog, community, and wall-of-love pages
- Fix blog canonical/OG URLs to be locale-aware
- Fix introducing-cmux .split(": ") by using separate label/desc translation keys
- Revert legal page titles to English (legal content stays English-only)
- Add focus-visible ring to language switcher for keyboard accessibility
- Preserve query string and hash when switching locale
- Convert site-footer to server component (remove unnecessary "use client")
- Remove .toLowerCase() on translated text in community page
- Add /docs/browser-automation and /wall-of-love to sitemap
- Fix keyboard-shortcuts jump link visibility with trimmed query
- Deduplicate blogSlugs by importing from blog-posts.ts
- Add typingCodingAgents/typingMultitasking translation keys to all locales
- Fix Spanish accent/tilde issues in es.json testimonials
- Fix nested <a> tag in homepage keyboard shortcuts feature
- Remove unused setRequestLocale import from homepage
* Convert remaining layout/index metadata to generateMetadata
- Root layout: locale-aware title, description, OG, and Twitter card metadata
- Docs layout: translated title template
- Blog layout: translated title template
- Blog index: locale-aware metadata
* Add translated metadata keys to all locales, fix docs redirect
- Add meta.title/description/ogDescription to all 18 non-English locales
- Add docs.layoutTitle, blog.layoutTitle/metaTitle/metaDescription to all locales
- Add blog post metadata (zenOfCmux, cmdShiftU, showHnLaunch, introducingCmux) to all locales
- Add community.metaTitle/metaDescription to all locales
- Fix docs index redirect to preserve locale prefix
* Add translated docs page metaTitle keys to all locales
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
export const testimonials = [
|
|
{
|
|
key: "mitchellh",
|
|
name: "Mitchell Hashimoto",
|
|
handle: "@mitchellh",
|
|
subtitle: "Creator of Ghostty and founder of HashiCorp",
|
|
avatar: "/avatars/mitchellh.jpg",
|
|
text: "Another day another libghostty-based project, this time a macOS terminal with vertical tabs, better organization/notifications, embedded/scriptable browser specifically targeted towards people who use a ton of terminal-based agentic workflows.",
|
|
lang: "en",
|
|
url: "https://x.com/mitchellh/status/2024913161238053296",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "schrockn",
|
|
name: "Nick Schrock",
|
|
handle: "@schrockn",
|
|
subtitle: "Creator of Dagster. GraphQL co-creator.",
|
|
avatar: "/avatars/schrockn.jpg",
|
|
text: "This is exactly the product I've been looking for. After two hours this am I've in love.",
|
|
lang: "en",
|
|
url: "https://x.com/schrockn/status/2025182278637207857",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "egrefen",
|
|
name: "Edward Grefenstette",
|
|
handle: "@egrefen",
|
|
subtitle: "Director of Research at Google DeepMind",
|
|
avatar: "/avatars/egrefen.jpg",
|
|
text: "I've been using this all weekend and it's amazing.",
|
|
lang: "en",
|
|
url: "https://x.com/egrefen/status/2026806171563184199",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "max4c",
|
|
name: "Max Forsey",
|
|
handle: "@max4c_",
|
|
avatar: "/avatars/max4c_.jpg",
|
|
text: "this has been my favorite tool for past two weeks",
|
|
lang: "en",
|
|
url: "https://x.com/max4c_/status/2027266664270889204",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "asaza",
|
|
name: "あさざ",
|
|
handle: "@asaza_0928",
|
|
avatar: "/avatars/asaza_0928.jpg",
|
|
text: "cmux 良さそうすぎてついにバイバイ VSCode するときなのかもしれない",
|
|
lang: "ja",
|
|
url: "https://x.com/asaza_0928/status/2026057269075698015",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "johnthedebs",
|
|
name: "johnthedebs",
|
|
handle: "johnthedebs",
|
|
avatar: null,
|
|
text: "Hey, this looks seriously awesome. Love the ideas here, specifically: the programmability, layered UI, browser w/ api. Looking forward to giving this a spin. Also want to add that I really appreciate Mitchell Hashimoto creating libghostty; it feels like an exciting time to be a terminal user.",
|
|
lang: "en",
|
|
url: "https://news.ycombinator.com/item?id=47083596",
|
|
platform: "hn" as const,
|
|
},
|
|
{
|
|
key: "joeriddles",
|
|
name: "Joe Riddle",
|
|
handle: "@joeriddles10",
|
|
avatar: "/avatars/joeriddles10.jpg",
|
|
text: "Vertical tabs in my terminal \u{1F924} I never thought of that before. I use and love Firefox vertical tabs.",
|
|
lang: "en",
|
|
url: "https://x.com/joeriddles10/status/2024914132416561465",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "dchu17",
|
|
name: "dchu17",
|
|
handle: "dchu17",
|
|
avatar: null,
|
|
text: "Gave this a run and it was pretty intuitive. Good work!",
|
|
lang: "en",
|
|
url: "https://news.ycombinator.com/item?id=47082577",
|
|
platform: "hn" as const,
|
|
},
|
|
{
|
|
key: "afruth",
|
|
name: "afruth",
|
|
handle: "u/afruth",
|
|
avatar: null,
|
|
text: "I like it, ran it in the past day on three parallel projects each with several worktrees. Having this paired with lazygit and yazi / nvim made me a bit more productive than usual without having to chase multiple ghostty / iTerm instances. Also feels more natural than tmux.",
|
|
lang: "en",
|
|
url: "https://www.reddit.com/r/ClaudeCode/comments/1r9g45u/comment/o6sxbr3/",
|
|
platform: "reddit" as const,
|
|
},
|
|
{
|
|
key: "northprint",
|
|
name: "Norihiro Narayama",
|
|
handle: "@northprint",
|
|
avatar: "/avatars/northprint.jpg",
|
|
text: "cmux良さそうなので入れてみたけれど、良い",
|
|
lang: "ja",
|
|
url: "https://x.com/northprint/status/2025740286677434581",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "indykish",
|
|
name: "Kishore Neelamegam",
|
|
handle: "@indykish",
|
|
avatar: "/avatars/indykish.jpg",
|
|
text: "cmux is pretty good.",
|
|
lang: "en",
|
|
url: "https://x.com/indykish/status/2025318347970412673",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "kataring",
|
|
name: "かたりん",
|
|
handle: "@kataring",
|
|
avatar: "/avatars/kataring.jpg",
|
|
text: "cmux.dev に乗り換えた",
|
|
lang: "ja",
|
|
url: "https://x.com/kataring/status/2026189035056832718",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "scottw",
|
|
name: "Scott Watermasysk",
|
|
handle: "@scottw",
|
|
avatar: "/avatars/scottw.jpg",
|
|
text: "This has been such a useful find. I can't recommend it enough.",
|
|
lang: "en",
|
|
url: "https://x.com/scottw/status/2026806893067551084",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "johnblythe",
|
|
name: "John Blythe",
|
|
handle: "@johnblythe",
|
|
avatar: "/avatars/johnblythe.jpg",
|
|
text: "grabbed this over the weekend and loved it. been waiting for something like this.",
|
|
lang: "en",
|
|
url: "https://x.com/johnblythe/status/2026812731844637010",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "bchris91",
|
|
name: "Christopher",
|
|
handle: "@BChris91",
|
|
avatar: "/avatars/bchris91.jpg",
|
|
text: "This is exactly what I've wanted. Amazing job thank you!",
|
|
lang: "en",
|
|
url: "https://x.com/BChris91/status/2026821091637838273",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "connorelsea",
|
|
name: "Connor",
|
|
handle: "@connorelsea",
|
|
avatar: "/avatars/connorelsea.jpg",
|
|
text: "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh",
|
|
lang: "en",
|
|
url: "https://x.com/connorelsea/status/2026867085750440390",
|
|
platform: "x" as const,
|
|
},
|
|
{
|
|
key: "tonkotsuboy",
|
|
name: "鹿野 壮 Takeshi Kano",
|
|
handle: "@tonkotsuboy_com",
|
|
avatar: "/avatars/tonkotsuboy_com.jpg",
|
|
text: "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた\uD83D\uDCBB 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える",
|
|
lang: "ja",
|
|
url: "https://x.com/tonkotsuboy_com/status/2028458464801108212",
|
|
platform: "x" as const,
|
|
},
|
|
];
|
|
|
|
export type Testimonial = (typeof testimonials)[number];
|
|
|
|
export function PlatformIcon({ platform }: { platform: "x" | "hn" | "reddit" }) {
|
|
if (platform === "x") {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
className="text-muted"
|
|
>
|
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (platform === "reddit") {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="#FF4500"
|
|
className="text-muted"
|
|
>
|
|
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.066 13.71c.147.307.222.644.222.994 0 1.987-2.752 3.596-6.148 3.596s-6.148-1.61-6.148-3.596c0-.35.075-.687.222-.994a1.426 1.426 0 01-.468-1.068c0-.798.648-1.446 1.446-1.446.39 0 .744.155 1.003.408 1.018-.67 2.396-1.09 3.917-1.148l.734-3.296a.348.348 0 01.416-.268l2.39.53a1.05 1.05 0 011.976.49c0 .58-.47 1.05-1.05 1.05a1.05 1.05 0 01-1.04-1.18l-2.07-.46-.625 2.81c1.465.076 2.786.493 3.768 1.14a1.44 1.44 0 011.003-.408c.798 0 1.446.648 1.446 1.446 0 .416-.176.79-.468 1.054zM9.06 12.61c-.58 0-1.05.47-1.05 1.05s.47 1.05 1.05 1.05 1.05-.47 1.05-1.05-.47-1.05-1.05-1.05zm5.88 0c-.58 0-1.05.47-1.05 1.05s.47 1.05 1.05 1.05 1.05-.47 1.05-1.05-.47-1.05-1.05-1.05zm-5.04 3.48c-.1-.1-.1-.26 0-.36.1-.1.26-.1.36 0 .58.58 1.39.87 2.19.87s1.61-.29 2.19-.87c.1-.1.26-.1.36 0 .1.1.1.26 0 .36-.68.68-1.59 1.05-2.55 1.05s-1.87-.37-2.55-1.05z" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 256 256"
|
|
className="text-muted"
|
|
>
|
|
<rect width="256" height="256" rx="28" fill="#ff6600" />
|
|
<text
|
|
x="128"
|
|
y="188"
|
|
fontSize="180"
|
|
fontWeight="bold"
|
|
fontFamily="sans-serif"
|
|
fill="white"
|
|
textAnchor="middle"
|
|
>
|
|
Y
|
|
</text>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function Initials({ name }: { name: string }) {
|
|
const initials = name
|
|
.split(/[\s_-]+/)
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
return (
|
|
<div className="w-10 h-10 rounded-full bg-code-bg border border-border flex items-center justify-center text-xs font-medium text-muted shrink-0">
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the language family prefix for a locale (e.g., "zh" for "zh-CN").
|
|
*/
|
|
function langFamily(locale: string): string {
|
|
return locale.split("-")[0];
|
|
}
|
|
|
|
/**
|
|
* Get the translation to display for a testimonial in the given locale.
|
|
* Returns null if the testimonial is in the user's language.
|
|
*/
|
|
export function getTestimonialTranslation(
|
|
testimonial: Testimonial,
|
|
locale: string,
|
|
t: (key: string) => string
|
|
): string | null {
|
|
if (langFamily(locale) === langFamily(testimonial.lang)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return t(testimonial.key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function TestimonialCard({
|
|
testimonial,
|
|
translation,
|
|
}: {
|
|
testimonial: Testimonial;
|
|
translation?: string | null;
|
|
}) {
|
|
return (
|
|
<a
|
|
href={testimonial.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group block rounded-xl border border-border p-5 hover:bg-code-bg transition-colors break-inside-avoid mb-4"
|
|
>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
{testimonial.avatar ? (
|
|
<img
|
|
src={testimonial.avatar}
|
|
alt={testimonial.name}
|
|
width={40}
|
|
height={40}
|
|
className="rounded-full shrink-0"
|
|
/>
|
|
) : (
|
|
<Initials name={testimonial.name} />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium text-sm truncate">
|
|
{testimonial.name}
|
|
</div>
|
|
{"subtitle" in testimonial && testimonial.subtitle && (
|
|
<div className="text-xs text-muted truncate">
|
|
{testimonial.subtitle}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted truncate">
|
|
{testimonial.handle}
|
|
</div>
|
|
</div>
|
|
<PlatformIcon platform={testimonial.platform} />
|
|
</div>
|
|
<p className="text-[15px] leading-relaxed text-muted group-hover:text-foreground transition-colors">
|
|
{testimonial.text}
|
|
</p>
|
|
{translation && (
|
|
<p className="text-xs text-muted/60 mt-1.5 italic">
|
|
{translation}
|
|
</p>
|
|
)}
|
|
</a>
|
|
);
|
|
}
|