Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
{
"home": {
"signIn": "Sign in",
"signOut": "Sign out",
"subtitle": "A Next.js starter template, packed with features like TypeScript, Tailwind CSS, Next-auth, Eslint, Stripe, testing tools and more. Jumpstart your project with efficiency and style.",
"getStartedButton": "Get started"
},
"seo": {
"title": "UniClipboard — Open-source encrypted clipboard across Mac, Windows, Linux, iPhone & Android",
"titleTemplate": "%s | UniClipboard",
"description": "The open-source, end-to-end encrypted universal clipboard. Mac, Windows, Linux, iPhone, Android — one clipboard, no cloud account, no server in the middle. The cross-platform alternative to iCloud Universal Clipboard.",
"softwareDescription": "Free, open-source, end-to-end encrypted universal clipboard for macOS, Windows, Linux, iOS, and Android. Copy on one device and paste on another with no account and no server.",
"keywords": "UniClipboard, universal clipboard, clipboard sync, cross-platform clipboard, Mac Windows clipboard, Linux clipboard sync, iPhone clipboard, Android clipboard, mobile clipboard sync, open source clipboard, end-to-end encrypted clipboard, iCloud universal clipboard alternative, clipboard manager, copy paste between devices",
"ogAlt": "UniClipboard — one clipboard for every device you own"
},
Expand Down
8 changes: 0 additions & 8 deletions messages/pl.json

This file was deleted.

1,088 changes: 1,088 additions & 0 deletions messages/ru.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"title": "UniClipboard — 跨 Mac / Windows / Linux / iPhone / Android 的开源加密剪贴板",
"titleTemplate": "%s | UniClipboard",
"description": "开源、端到端加密的通用剪贴板。文本、图片、文件在 Mac、Windows、Linux、iPhone、Android 之间实时同步——无需账号,也不经过任何第三方服务器。iCloud 通用剪贴板的跨平台开源替代。",
"softwareDescription": "免费、开源、端到端加密的跨平台通用剪贴板。文本、图片、文件在 macOS、Windows、Linux、iPhone、Android 之间实时同步,无需账号、无需服务器。",
"keywords": "UniClipboard, 剪贴板同步, 剪贴板同步, 跨设备剪贴板, Mac Windows 剪贴板, Linux 剪贴板同步, iPhone 剪贴板同步, Android 剪贴板同步, 手机电脑剪贴板, 开源剪贴板, 端到端加密剪贴板, iCloud 通用剪贴板替代, 通用剪贴板, 剪贴板管理器, 跨平台剪贴板, 跨设备复制粘贴",
"ogAlt": "UniClipboard — 你的剪贴板,在所有设备上"
},
Expand Down
54 changes: 35 additions & 19 deletions src/__tests__/integration/landing-release-state.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import enMessages from "../../../messages/en.json";
import ruMessages from "../../../messages/ru.json";
import zhMessages from "../../../messages/zh.json";

jest.mock("../../lib/site-config", () => ({
Expand All @@ -11,6 +12,7 @@ jest.mock("../../lib/site-config", () => ({
}));

import LandingPage from "@/app/[locale]/page";
import { routing } from "@/i18n/routing";

jest.mock(
"@/lib/release-feed/fetch-stable-release",
Expand All @@ -27,7 +29,13 @@ jest.mock(
{ virtual: true },
);

let currentLocale: "en" | "zh" = "en";
const messagesByLocale: Record<string, unknown> = {
en: enMessages,
zh: zhMessages,
ru: ruMessages,
};

let currentLocale = "en";

const resolveDotPath = (
dictionary: Record<string, unknown>,
Expand All @@ -51,7 +59,7 @@ jest.mock("next-intl/server", () => ({
arg: string | { locale?: string; namespace?: string },
) => {
const namespace = typeof arg === "string" ? arg : (arg?.namespace ?? "");
const messages = currentLocale === "en" ? enMessages : zhMessages;
const messages = messagesByLocale[currentLocale] ?? enMessages;
const dictionary = namespace.split(".").reduce<unknown>((cursor, part) => {
if (cursor && typeof cursor === "object" && part in (cursor as object))
return (cursor as Record<string, unknown>)[part];
Expand Down Expand Up @@ -89,23 +97,31 @@ describe("landing release state integration", () => {
expect(mockedNormalizeStableRelease).not.toHaveBeenCalled();
});

it("keeps landing.finalCta keys aligned across locales", () => {
const enKeys = Object.keys(
enMessages.landing.finalCta as unknown as Record<string, unknown>,
).sort();
const zhKeys = Object.keys(
zhMessages.landing.finalCta as unknown as Record<string, unknown>,
).sort();
expect(zhKeys).toEqual(enKeys);
});
// A missing key does not fail the build — next-intl falls back to echoing the
// key path, so an untranslated locale ships looking like `landing.hero.title`.
// Comparing every leaf path against the default locale is what catches it.
const leafPaths = (value: unknown, prefix = ""): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item, i) => leafPaths(item, `${prefix}[${i}]`));
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, child]) =>
leafPaths(child, prefix ? `${prefix}.${key}` : key),
);
}
return [prefix];
};

const expectedPaths = leafPaths(enMessages).sort();

it.each(routing.locales.filter((locale) => locale !== routing.defaultLocale))(
"keeps every message key aligned between en and %s",
(locale) => {
expect(leafPaths(messagesByLocale[locale]).sort()).toEqual(expectedPaths);
},
);

it("keeps landing.download keys aligned across locales", () => {
const enKeys = Object.keys(
enMessages.landing.download as unknown as Record<string, unknown>,
).sort();
const zhKeys = Object.keys(
zhMessages.landing.download as unknown as Record<string, unknown>,
).sort();
expect(zhKeys).toEqual(enKeys);
it.each(routing.locales)("has a message file for %s", (locale) => {
expect(messagesByLocale[locale]).toBeDefined();
});
});
70 changes: 63 additions & 7 deletions src/__tests__/unit/navigation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { fireEvent, render, screen } from "@testing-library/react";
import type { AnchorHTMLAttributes } from "react";

import { Navigation } from "@/components/landing/Navigation";
import { localeMeta } from "@/i18n/locale-meta";
import { routing } from "@/i18n/routing";

const mockUseLocale = jest.fn();
jest.mock("next-intl", () => ({
Expand Down Expand Up @@ -36,30 +38,84 @@ describe("Navigation", () => {
mockUseLocale.mockReturnValue("en");
});

it("renders language tabs and the theme toggle", () => {
// The language switcher is a closed dropdown until its trigger is clicked; the
// trigger's accessible name is the active locale's own name.
const openLangMenu = (activeLocale: keyof typeof localeMeta = "en") => {
fireEvent.click(
screen.getByRole("button", { name: localeMeta[activeLocale].nativeName }),
);
};

it("renders the language switcher trigger and the theme toggle", () => {
render(<Navigation />);

expect(screen.getByRole("button", { name: "ZH" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "EN" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: localeMeta.en.nativeName }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Toggle theme" }),
).toBeInTheDocument();
});

it("switches locale via the inline pathname-preserving router call", () => {
it("reveals one option per routed locale once opened", () => {
render(<Navigation />);
openLangMenu();

for (const locale of routing.locales) {
expect(
screen.getByRole("menuitemradio", {
name: localeMeta[locale].nativeName,
}),
).toBeInTheDocument();
}
});

fireEvent.click(screen.getByRole("button", { name: "ZH" }));
it("switches to Russian from the language menu", () => {
render(<Navigation />);
openLangMenu();

fireEvent.click(
screen.getByRole("menuitemradio", { name: localeMeta.ru.nativeName }),
);
expect(replace).toHaveBeenCalledWith("/", { locale: "ru" });
});

it("switches to Chinese from the language menu", () => {
render(<Navigation />);
openLangMenu();

fireEvent.click(
screen.getByRole("menuitemradio", { name: localeMeta.zh.nativeName }),
);
expect(replace).toHaveBeenCalledWith("/", { locale: "zh" });
});

it("does not call replace when clicking the active locale", () => {
it("does not call replace when choosing the active locale", () => {
render(<Navigation />);
openLangMenu();

fireEvent.click(screen.getByRole("button", { name: "EN" }));
fireEvent.click(
screen.getByRole("menuitemradio", { name: localeMeta.en.nativeName }),
);
expect(replace).not.toHaveBeenCalled();
});

it("hides the article sections in locales that have no article content", () => {
mockUseLocale.mockReturnValue("ru");
render(<Navigation />);

expect(
screen.queryByRole("link", { name: "blog" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "compare" }),
).not.toBeInTheDocument();
// Non-article sections stay available.
expect(
screen.getAllByRole("link", { name: "changelog" }).length,
).toBeGreaterThan(0);
});

it("advances the theme one step in the system → light → dark cycle", () => {
render(<Navigation />);

Expand Down
12 changes: 6 additions & 6 deletions src/app/[locale]/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ImageResponse } from "takumi-js/response";

import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/components/og/OgFrame";
import { getAllPublishedSlugs } from "@/db/articles";
import type { ArticleLocale } from "@/lib/article-content";
import { ARTICLE_LOCALES, isArticleLocale } from "@/lib/article-content";
import { loadOgFonts } from "@/lib/og/fonts";

export const size = OG_SIZE;
Expand All @@ -16,18 +16,18 @@ export async function generateStaticParams() {
const all = await getAllPublishedSlugs();
return all
.filter((a) => a.category === "blog")
.flatMap((a) => [
{ locale: "en", slug: a.slug },
{ locale: "zh", slug: a.slug },
]);
.flatMap((a) =>
ARTICLE_LOCALES.map((locale) => ({ locale, slug: a.slug })),
);
}

type Params = { params: Promise<{ locale: string; slug: string }> };

async function loadArticleHero(slug: string, locale: string) {
try {
if (!isArticleLocale(locale)) return null;
const { getArticle } = await import("@/db/articles");
const article = await getArticle("blog", slug, locale as ArticleLocale);
const article = await getArticle("blog", slug, locale);
if (!article || article.content.contentType !== "markdown") return null;
return {
title: article.content.hero.title,
Expand Down
19 changes: 11 additions & 8 deletions src/app/[locale]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
MarkdownArticleLayout,
} from "@/components/article/MarkdownArticleLayout";
import { getAllPublishedSlugs, getArticle } from "@/db/articles";
import type { ArticleLocale } from "@/lib/article-content";
import { ARTICLE_LOCALES, isArticleLocale } from "@/lib/article-content";

const CATEGORY = "blog" as const;

Expand All @@ -18,17 +18,17 @@ export async function generateStaticParams() {
const all = await getAllPublishedSlugs();
return all
.filter((a) => a.category === CATEGORY)
.flatMap((a) => [
{ locale: "en", slug: a.slug },
{ locale: "zh", slug: a.slug },
]);
.flatMap((a) =>
ARTICLE_LOCALES.map((locale) => ({ locale, slug: a.slug })),
);
}

export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const article = await getArticle(CATEGORY, slug, locale as ArticleLocale);
if (!isArticleLocale(locale)) return {};
const article = await getArticle(CATEGORY, slug, locale);
if (!article || article.content.contentType !== "markdown") return {};
return buildMarkdownArticleMetadata(
{
Expand All @@ -43,7 +43,10 @@ export async function generateMetadata({

export default async function BlogArticlePage({ params }: PageProps) {
const { locale, slug } = await params;
const article = await getArticle(CATEGORY, slug, locale as ArticleLocale);
// `articleTranslations.locale` is a Postgres enum of ARTICLE_LOCALES only, so
// a locale outside that set must be rejected before it reaches the query.
if (!isArticleLocale(locale)) notFound();
const article = await getArticle(CATEGORY, slug, locale);
if (!article || article.content.contentType !== "markdown") notFound();

return (
Expand All @@ -54,7 +57,7 @@ export default async function BlogArticlePage({ params }: PageProps) {
datePublished: article.datePublished,
}}
content={article.content}
locale={locale as ArticleLocale}
locale={locale}
/>
);
}
Loading
Loading