Skip to content

Commit 3b8ef0b

Browse files
committed
add i18n translation hygiene
1 parent 9219630 commit 3b8ef0b

47 files changed

Lines changed: 2045 additions & 569 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/validate-app.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Validate App
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
check:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
15+
steps:
16+
- name: Check out repository
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: npm
24+
25+
- name: Install dependencies
26+
run: npm ci
27+
28+
- name: Run app checks
29+
run: npm run check

messages/de.json

Lines changed: 360 additions & 3 deletions
Large diffs are not rendered by default.

messages/en.json

Lines changed: 359 additions & 2 deletions
Large diffs are not rendered by default.

messages/es.json

Lines changed: 360 additions & 3 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"supabase:reset": "supabase db reset",
1313
"seed:local-media": "node scripts/seed-local-media.mjs",
1414
"supabase:diff": "supabase db diff",
15+
"i18n:check": "node scripts/check-i18n-messages.mjs",
16+
"check": "npm run i18n:check && npm run format:check",
1517
"format": "prettier --write .",
1618
"format:check": "prettier --check ."
1719
},

scripts/check-i18n-messages.mjs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import process from "node:process";
4+
5+
const rootDir = process.cwd();
6+
const configPath = path.join(rootDir, "src/i18n/config.ts");
7+
const messagesDir = path.join(rootDir, "messages");
8+
9+
function readConfig() {
10+
const source = fs.readFileSync(configPath, "utf8");
11+
const localesMatch = source.match(/locales\s*=\s*(\[[^\]]+\])\s*as const/);
12+
const defaultLocaleMatch = source.match(
13+
/defaultLocale:\s*Locale\s*=\s*["']([^"']+)["']/
14+
);
15+
16+
if (!localesMatch) {
17+
throw new Error(`Could not find the locales array in ${configPath}`);
18+
}
19+
20+
const locales = JSON.parse(localesMatch[1].replaceAll("'", '"'));
21+
const defaultLocale = defaultLocaleMatch?.[1] ?? locales[0];
22+
23+
if (!locales.includes(defaultLocale)) {
24+
throw new Error(
25+
`Default locale "${defaultLocale}" is not listed in ${configPath}`
26+
);
27+
}
28+
29+
return { locales, defaultLocale };
30+
}
31+
32+
function readMessages(locale) {
33+
const filePath = path.join(messagesDir, `${locale}.json`);
34+
35+
if (!fs.existsSync(filePath)) {
36+
throw new Error(
37+
`Missing message file: ${path.relative(rootDir, filePath)}`
38+
);
39+
}
40+
41+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
42+
}
43+
44+
function valueType(value) {
45+
if (Array.isArray(value)) {
46+
return "array";
47+
}
48+
49+
if (value === null) {
50+
return "null";
51+
}
52+
53+
return typeof value;
54+
}
55+
56+
function flattenMessages(value, pathParts = [], entries = new Map()) {
57+
if (value && typeof value === "object" && !Array.isArray(value)) {
58+
entries.set(pathParts.join("."), {
59+
type: "object",
60+
value,
61+
});
62+
63+
for (const [key, childValue] of Object.entries(value)) {
64+
flattenMessages(childValue, [...pathParts, key], entries);
65+
}
66+
67+
return entries;
68+
}
69+
70+
entries.set(pathParts.join("."), {
71+
type: valueType(value),
72+
value,
73+
});
74+
75+
return entries;
76+
}
77+
78+
function leafKeys(entries) {
79+
return [...entries]
80+
.filter(([, entry]) => entry.type !== "object")
81+
.map(([key]) => key)
82+
.sort();
83+
}
84+
85+
function extractIcuArguments(message) {
86+
const argumentsSet = new Set();
87+
88+
for (let index = 0; index < message.length; index += 1) {
89+
if (message[index] !== "{") {
90+
continue;
91+
}
92+
93+
const match = message
94+
.slice(index + 1)
95+
.match(/^([A-Za-z_][\w]*)\s*(?:[,}])/);
96+
97+
if (match) {
98+
argumentsSet.add(match[1]);
99+
}
100+
101+
let depth = 1;
102+
index += 1;
103+
104+
while (index < message.length && depth > 0) {
105+
if (message[index] === "{") {
106+
depth += 1;
107+
} else if (message[index] === "}") {
108+
depth -= 1;
109+
}
110+
111+
index += 1;
112+
}
113+
}
114+
115+
return [...argumentsSet].sort();
116+
}
117+
118+
function extractRichTextTags(message) {
119+
const tags = new Set();
120+
const tagPattern = /<\/?([A-Za-z][\w]*)\b[^>]*>/g;
121+
122+
for (const match of message.matchAll(tagPattern)) {
123+
tags.add(match[1]);
124+
}
125+
126+
return [...tags].sort();
127+
}
128+
129+
function listDifference(left, right) {
130+
const rightSet = new Set(right);
131+
return left.filter((item) => !rightSet.has(item));
132+
}
133+
134+
function sameList(left, right) {
135+
return (
136+
left.length === right.length &&
137+
left.every((item, index) => item === right[index])
138+
);
139+
}
140+
141+
function formatList(items) {
142+
return items.map((item) => `- ${item}`).join("\n");
143+
}
144+
145+
function compareLocale(locale, baselineLocale, baselineEntries, localeEntries) {
146+
const problems = [];
147+
const baselineLeaves = leafKeys(baselineEntries);
148+
const localeLeaves = leafKeys(localeEntries);
149+
const missingKeys = listDifference(baselineLeaves, localeLeaves);
150+
const extraKeys = listDifference(localeLeaves, baselineLeaves);
151+
152+
if (missingKeys.length > 0) {
153+
problems.push(
154+
`messages/${locale}.json is missing keys from messages/${baselineLocale}.json:\n${formatList(
155+
missingKeys
156+
)}`
157+
);
158+
}
159+
160+
if (extraKeys.length > 0) {
161+
problems.push(
162+
`messages/${locale}.json has extra keys not found in messages/${baselineLocale}.json:\n${formatList(
163+
extraKeys
164+
)}`
165+
);
166+
}
167+
168+
for (const [key, baselineEntry] of baselineEntries) {
169+
const localeEntry = localeEntries.get(key);
170+
171+
if (!localeEntry) {
172+
continue;
173+
}
174+
175+
if (baselineEntry.type !== localeEntry.type) {
176+
problems.push(
177+
`messages/${locale}.json has a structural mismatch at "${key}": expected ${baselineEntry.type}, found ${localeEntry.type}`
178+
);
179+
continue;
180+
}
181+
182+
if (localeEntry.type !== "string") {
183+
continue;
184+
}
185+
186+
if (localeEntry.value.trim() === "") {
187+
problems.push(`messages/${locale}.json has an empty string at "${key}"`);
188+
continue;
189+
}
190+
191+
const baselineArguments = extractIcuArguments(baselineEntry.value);
192+
const localeArguments = extractIcuArguments(localeEntry.value);
193+
194+
if (!sameList(baselineArguments, localeArguments)) {
195+
problems.push(
196+
`messages/${locale}.json has ICU placeholder mismatch at "${key}": expected {${baselineArguments.join(
197+
", "
198+
)}}, found {${localeArguments.join(", ")}}`
199+
);
200+
}
201+
202+
const baselineTags = extractRichTextTags(baselineEntry.value);
203+
const localeTags = extractRichTextTags(localeEntry.value);
204+
205+
if (!sameList(baselineTags, localeTags)) {
206+
problems.push(
207+
`messages/${locale}.json has rich-text tag mismatch at "${key}": expected <${baselineTags.join(
208+
">, <"
209+
)}>, found <${localeTags.join(">, <")}>`
210+
);
211+
}
212+
}
213+
214+
return problems;
215+
}
216+
217+
function main() {
218+
const { locales, defaultLocale } = readConfig();
219+
const baselineMessages = readMessages(defaultLocale);
220+
const baselineEntries = flattenMessages(baselineMessages);
221+
const problems = [];
222+
223+
for (const locale of locales) {
224+
const messages = readMessages(locale);
225+
const entries = flattenMessages(messages);
226+
227+
for (const [key, entry] of entries) {
228+
if (entry.type === "string" && entry.value.trim() === "") {
229+
problems.push(
230+
`messages/${locale}.json has an empty string at "${key}"`
231+
);
232+
}
233+
}
234+
235+
if (locale === defaultLocale) {
236+
continue;
237+
}
238+
239+
problems.push(
240+
...compareLocale(locale, defaultLocale, baselineEntries, entries)
241+
);
242+
}
243+
244+
if (problems.length > 0) {
245+
console.error(
246+
`i18n message check failed with ${problems.length} problem(s):`
247+
);
248+
console.error("");
249+
console.error(problems.join("\n\n"));
250+
process.exitCode = 1;
251+
return;
252+
}
253+
254+
console.log(
255+
`i18n message check passed for ${locales.length} locale(s): ${locales.join(
256+
", "
257+
)}`
258+
);
259+
}
260+
261+
try {
262+
main();
263+
} catch (error) {
264+
console.error(error instanceof Error ? error.message : error);
265+
process.exitCode = 1;
266+
}

src/app/(core)/(interact)/(centered)/profile/page.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import LegalFooter from "@/components/LegalFooter";
77
import { styled } from "@pigment-css/react";
88
import { Suspense } from "react";
99
import Toast from "@/components/Toast";
10+
import { getTranslations } from "next-intl/server";
1011

1112
export const metadata = {
1213
title: "Profile",
1314
};
1415

1516
// Keep URL-based feedback in a client leaf so server rendering is driven by auth/data only.
1617
export default async function ProfilePage() {
18+
const t = await getTranslations("Profile.sections");
1719
const supabase = await createClient();
1820
// Get the authenticated user first, then fetch profile data in parallel.
1921
const {
@@ -42,17 +44,17 @@ export default async function ProfilePage() {
4244
</NakedSection>
4345

4446
<Section>
45-
<h2>Listings</h2>
47+
<h2>{t("listings")}</h2>
4648
<ProfileListings user={user} profile={profile} listings={listings} />
4749
</Section>
4850

4951
<Section>
50-
<h2>Account</h2>
52+
<h2>{t("account")}</h2>
5153
<ProfileAccountSettings user={user} profile={profile} />
5254
</Section>
5355

5456
<Section>
55-
<h2>Actions</h2>
57+
<h2>{t("actions")}</h2>
5658
<ProfileActions listings={listings} />
5759
</Section>
5860

src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import StaticPageSection from "@/components/StaticPageSection";
1212
import HeaderBlock from "@/components/HeaderBlock";
1313
import FooterBlock from "@/components/FooterBlock";
1414
import { getNewsletterIssueImageUrl } from "@/utils/storage";
15+
import { getTranslations } from "next-intl/server";
1516

1617
type NewsletterIssuePageProps = {
1718
params: Promise<{ slug: string }>;
@@ -54,6 +55,7 @@ export default async function NewsletterIssuePage({
5455
params,
5556
}: NewsletterIssuePageProps) {
5657
const { slug } = await params;
58+
const t = await getTranslations("Newsletter");
5759
const { metadata, customMetadata, formattedDate } =
5860
await getNewsletterIssueMetadata(slug);
5961
const title = customMetadata.verboseTitle
@@ -75,8 +77,11 @@ export default async function NewsletterIssuePage({
7577
<section>
7678
<StaticPageHeader
7779
title={title}
78-
subtitle={`Issue #${issueNumber} · Published ${formattedDate}`}
79-
parent="Newsletter"
80+
subtitle={t("issueSubtitle", {
81+
number: issueNumber,
82+
date: formattedDate,
83+
})}
84+
parent={t("parent")}
8085
/>
8186
<LongformTextContainer>
8287
<NewsletterIssueMarkdown />
@@ -85,14 +90,17 @@ export default async function NewsletterIssuePage({
8590

8691
<StaticPageSection>
8792
<HeaderBlock>
88-
<h2>Get these in your inbox</h2>
93+
<h2>{t("inboxTitle")}</h2>
8994
<p>{siteConfig.newsletter.description}</p>
9095
</HeaderBlock>
9196
<NewsletterCallout />
9297
<FooterBlock>
9398
<p>
94-
Or subscribe to the{" "}
95-
<Link href="/newsletter/feed.xml">RSS feed</Link>.
99+
{t.rich("rss", {
100+
link: (chunks) => (
101+
<Link href="/newsletter/feed.xml">{chunks}</Link>
102+
),
103+
})}
96104
</p>
97105
</FooterBlock>
98106
</StaticPageSection>

0 commit comments

Comments
 (0)