feat(i18n): add Korean as a seventh end-to-end locale

Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
This commit is contained in:
aucun6352 committed 2026-08-26 16:42:45 +09:00
1 parent 3ce5411d19
commit f1b0420479
50 files changed
+10646 -46

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), and Polish (`pl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
+1 -1
View File
@@ -44,7 +44,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, and Polish end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+10526
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
@@ -40,7 +40,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
@@ -33,7 +33,7 @@ const EN_PATH = join(MESSAGES_DIR, "en.json");
// intentionally excluded — it carries accepted anglicisms (e.g. "Health
// Score") that would be noise here, and is covered by its own pinned checks in
// `i18n-locale-integrity`.
const LEAK_LOCALES = ["es", "fr", "it", "pl"] as const;
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko"] as const;
function flattenValues(
obj: unknown,
@@ -20,7 +20,7 @@ import { join } from "node:path";
* the key is a prop, not a call argument. Both halves are asserted here.
*/
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
/**
@@ -93,7 +93,7 @@ const NOT_IN_THE_FEED = [
/** What an action with no sentence of its own renders as. */
const FALLBACK_KEY = "recordSharing.activity.acted";
/** The six bundles the product ships. */
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
* `*` skips a leading dot and this tree has dot-directories. */
+1 -1
View File
@@ -25,7 +25,7 @@ import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/in
const ROOT = path.resolve(__dirname, "../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Every path the retired bridge occupied. */
const REMOVED_PATHS = [
+1 -1
View File
@@ -50,7 +50,7 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
"utf8",
),
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
...["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) =>
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
),
]);
+1 -1
View File
@@ -38,7 +38,7 @@ import {
* it — which is the part that rots silently.
*/
const ROOT = process.cwd();
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** The published description of the level, off the Zod schema itself. */
function accessDescription(): string {
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "../..");
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const REQUIRED_KEYS = [
// Security: adding a passkey now requires an existing sign-in factor.
+1 -1
View File
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const LOCALES = ["en", "de", "fr", "es", "it", "pl"] as const;
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
interface Messages {
notFound: {
@@ -69,7 +69,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ const MAX_OUTPUT_TOKENS = 600;
const requestSchema = z.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
locale: z.enum(["en", "de", "es", "fr", "it", "pl"]).optional(),
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko"]).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
+2 -1
View File
@@ -32,7 +32,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
+2 -1
View File
@@ -15,7 +15,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -202,7 +202,7 @@ describe("dashboard.*Short keys exist across all six locales", () => {
// Resolved at test time so the assertion runs against the current
// bundle, not a stale cached snapshot.
const locales = ["en", "de", "fr", "es", "it", "pl"] as const;
const locales = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
const messages = (await import(`../../../../messages/${locale}.json`))
@@ -15,7 +15,7 @@ import { useTranslations } from "@/lib/i18n/context";
* but the prose has not been hand-reviewed for register, regional
* vocabulary, or domain-specific nuance.
*
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL/KO
* today) and surfaces a small, dismissible strip at the top of the
* authenticated app shell. It tells the user the translation is
* AI-maintained and links to the GitHub translation-feedback issue
@@ -66,7 +66,7 @@ describe("<OfflineBanner>", () => {
});
it("ships the message key in every supported locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const locale of ["en", "de", "fr", "es", "it", "pl", "ko"] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
}
@@ -71,7 +71,7 @@ export interface NaturalLanguageExtractorProps {
* UI locale forwarded to the route as a hint. The route accepts an
* undefined value and falls back to the resolver chain.
*/
locale?: "en" | "de" | "es" | "fr" | "it" | "pl";
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko";
/**
* Optional override of the API path so the wizard test harness can
* point at a mock server. Defaults to the production route.
@@ -108,7 +108,7 @@ describe("<ImportPanel> — SSR smoke", () => {
});
describe("<AppleHealthEstimateWarning>", () => {
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized estimate disclosure for %s",
(locale) => {
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
@@ -7,7 +7,7 @@ import { ALL_METRICS } from "@/lib/validations/thresholds";
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
@@ -218,7 +218,7 @@ describe("<AppleHealthCard>", () => {
}
});
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = {
@@ -400,7 +400,7 @@ function ProfileSettingsForm({
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
{["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
+3 -1
View File
@@ -3,7 +3,7 @@
import * as React from "react";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
import type { Locale as DateFnsLocale } from "date-fns";
import { cn } from "@/lib/utils";
@@ -325,6 +325,8 @@ function resolveDateFnsLocale(
return it;
case "pl":
return pl;
case "ko":
return ko;
default:
return enUS;
}
@@ -170,7 +170,7 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
// issue #659 — the message used to tell a self-hoster to set
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
// not something they can do. Every locale must name the mount instead.
const locales = ["de", "en", "es", "fr", "it", "pl"] as const;
const locales = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
@@ -20,11 +20,11 @@ import type { Locale } from "@/lib/i18n/config";
* - grounding: the literal "SNAPSHOT" token, which the grounding rule's
* `must_contain` pins in every locale.
* - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is
* identical across all six locales (an international registry, never
* identical across all seven locales (an international registry, never
* translated) and only ever appears inside the dose-safety contract.
*/
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"];
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
for (const locale of ALL_LOCALES) {
+1
View File
@@ -1130,6 +1130,7 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
es: "\n\nREPLY LANGUAGE: respond in Spanish. Mirror the user's register; use natural Spanish health vocabulary.",
it: "\n\nREPLY LANGUAGE: respond in Italian. Mirror the user's register; use natural Italian health vocabulary.",
pl: "\n\nREPLY LANGUAGE: respond in Polish. Mirror the user's register (formal Pan/Pani for medical-adjacent topics); use natural Polish health vocabulary.",
ko: "\n\nREPLY LANGUAGE: respond in Korean. Use the 해요체 register throughout (never 합쇼체 or plain 한다체); use natural Korean health vocabulary.",
};
/**
+2
View File
@@ -54,6 +54,8 @@ export function targetLanguageName(locale: Locale): string {
return "Italian";
case "pl":
return "Polish";
case "ko":
return "Korean";
default:
return "English";
}
+1 -1
View File
@@ -510,7 +510,7 @@ describe("the scale the popover quotes", () => {
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl"])(
it.each(["en", "de", "fr", "es", "it", "pl", "ko"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
+2 -1
View File
@@ -33,7 +33,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -27,7 +27,7 @@ import {
} from "@/lib/feature-flags";
const ROOT = path.resolve(__dirname, "../../../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Names the retired switch went by, on the wire and in the database. */
const RETIRED_NAMES = [
+2
View File
@@ -38,6 +38,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
es: "es-ES",
it: "it-IT",
pl: "pl-PL",
ko: "ko-KR",
};
export function resolveIntlLocale(locale: Locale): string {
@@ -310,5 +311,6 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
if (primary.startsWith("es")) return "es";
if (primary.startsWith("it")) return "it";
if (primary.startsWith("pl")) return "pl";
if (primary.startsWith("ko")) return "ko";
return "en";
}
@@ -18,7 +18,7 @@ import { locales, localeLanguageNames, coerceLocale } from "../config";
import { getPhaseMessage } from "@/lib/jobs/reminder-phases";
import { detectRefusal } from "@/lib/ai/coach/refusal";
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl"] as const;
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko"] as const;
describe("plural tiers", () => {
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
+3 -1
View File
@@ -1,4 +1,4 @@
export const locales = ["de", "en", "fr", "es", "it", "pl"] as const;
export const locales = ["de", "en", "fr", "es", "it", "pl", "ko"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
@@ -32,6 +32,7 @@ export const localeLanguageNames: Record<Locale, string> = {
es: "Spanish",
it: "Italian",
pl: "Polish",
ko: "Korean",
};
export const localeLabels: Record<Locale, string> = {
@@ -41,6 +42,7 @@ export const localeLabels: Record<Locale, string> = {
es: "Español",
it: "Italiano",
pl: "Polski",
ko: "한국어",
};
/**
+1
View File
@@ -49,6 +49,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
es: () => import("../../../messages/es.json"),
it: () => import("../../../messages/it.json"),
pl: () => import("../../../messages/pl.json"),
ko: () => import("../../../messages/ko.json"),
};
function initialCache(): Partial<Record<Locale, MessageBundle>> {
+3 -1
View File
@@ -9,7 +9,7 @@
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
* layout picks the active locale's bundle out of it for the RSC handoff
* and `getServerTranslator()` resolves against it. Do NOT import this
* module from client code — the six static JSON imports would drag
* module from client code — the seven static JSON imports would drag
* every locale back into the client chunk (the ~1.4 MiB regression the
* split removed). Client code uses `./load-locale` (static EN fallback
* floor + dynamic per-locale imports) and `./resolve-key` instead.
@@ -20,6 +20,7 @@ import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
@@ -36,6 +37,7 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
es: esMessages,
it: itMessages,
pl: plMessages,
ko: koMessages,
};
/**
@@ -173,7 +173,7 @@ describe("enqueueSummaryCatchUp", () => {
});
describe("the toggle copy discloses the catch-up", () => {
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
const bundle = JSON.parse(
@@ -163,7 +163,7 @@ describe("enqueueStatusGeneration", () => {
const send = vi.fn().mockResolvedValue("job-id");
getGlobalBoss.mockReturnValue({ send });
for (const locale of ["de", "en", "fr", "es", "it", "pl"] as const) {
for (const locale of ["de", "en", "fr", "es", "it", "pl", "ko"] as const) {
await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale });
}
+1 -1
View File
@@ -217,7 +217,7 @@ describe("buildMoodReminderPayload", () => {
expect(p.body).toBe("How are you feeling today?");
});
it.each(["es", "fr", "it", "pl"] as const)(
it.each(["es", "fr", "it", "pl", "ko"] as const)(
"returns native (non-EN) strings for locale=%s",
(locale) => {
const p = buildMoodReminderPayload(locale);
+2 -1
View File
@@ -79,7 +79,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -855,7 +855,7 @@ export const medicationExtractRequest = z
"Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.",
),
locale: z
.enum(["en", "de", "es", "fr", "it", "pl"])
.enum(["en", "de", "es", "fr", "it", "pl", "ko"])
.optional()
.describe("Optional UI locale hint for the model."),
today: z
+1 -1
View File
@@ -119,7 +119,7 @@ const profileSettings = z
.nullable()
.describe("Calendar date (`YYYY-MM-DD`), not an instant."),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable(),
timezone: z.string(),
unitPreference: z.enum(["metric", "imperial"]),
timeFormat: z.enum(["AUTO", "H12", "H24"]),
+1 -1
View File
@@ -59,7 +59,7 @@ const profilePatchSchema = z
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
dateOfBirth: z.string().date().nullable().optional(),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable().optional(),
timezone: timezoneSchema.optional(),
unitPreference: z.enum(["metric", "imperial"]).optional(),
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
+1 -1
View File
@@ -19,7 +19,7 @@ export const createManagedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
timezone: z
.string()
.min(1)
+57 -2
View File
@@ -63,6 +63,55 @@ const suggestionsDe: Record<string, string> = {
'Vorhersehbare Ersetzungen wie „@" statt „a" helfen nicht wesentlich',
};
const warningsKo: Record<string, string> = {
"Straight rows of keys are easy to guess":
"키보드에서 일렬로 놓인 키는 쉽게 추측돼요",
"Short keyboard patterns are easy to guess":
"짧은 키보드 패턴은 쉽게 추측돼요",
'Repeats like "aaa" are easy to guess': '"aaa" 같은 반복은 쉽게 추측돼요',
'Repeats like "abcabc" are easy to guess':
'"abcabc" 같은 반복은 쉽게 추측돼요',
"Sequences like abc or 6543 are easy to guess":
"abc나 6543 같은 연속은 쉽게 추측돼요",
"Recent years are easy to guess": "최근 연도는 쉽게 추측돼요",
"Dates are often easy to guess": "날짜는 대체로 쉽게 추측돼요",
"This is a top-10 common password": "가장 흔한 비밀번호 10개 안에 들어요",
"This is a top-100 common password": "가장 흔한 비밀번호 100개 안에 들어요",
"This is a very common password": "아주 흔한 비밀번호예요",
"This is similar to a commonly used password":
"자주 쓰이는 비밀번호와 비슷해요",
"A word by itself is easy to guess": "단어 하나만으로는 쉽게 추측돼요",
"Names and surnames by themselves are easy to guess":
"이름이나 성만으로는 쉽게 추측돼요",
"Common names and surnames are easy to guess":
"흔한 이름과 성은 쉽게 추측돼요",
};
const suggestionsKo: Record<string, string> = {
"Use a few words, avoid common phrases":
"단어를 여러 개 쓰고, 흔한 문구는 피해 주세요",
"No need for symbols, digits, or uppercase letters":
"기호, 숫자, 대문자를 꼭 넣지 않아도 돼요",
"Add another word or two. Uncommon words are better.":
"단어를 한두 개 더 붙여 주세요. 흔하지 않은 단어일수록 좋아요.",
"Use a longer keyboard pattern with more turns":
"방향이 자주 바뀌는 더 긴 키보드 패턴을 써 주세요",
"Avoid repeated words and characters": "반복되는 단어와 문자는 피해 주세요",
"Avoid sequences": "연속된 배열은 피해 주세요",
"Avoid recent years": "최근 연도는 피해 주세요",
"Avoid years that are associated with you":
"본인과 관련된 연도는 피해 주세요",
"Avoid dates and years that are associated with you":
"본인과 관련된 날짜와 연도는 피해 주세요",
"Capitalization doesn't help very much": "대문자로 바꿔도 큰 도움이 안 돼요",
"All-uppercase is almost as easy to guess as all-lowercase":
"전부 대문자로 써도 전부 소문자만큼 쉽게 추측돼요",
"Reversed words aren't much harder to guess":
"단어를 거꾸로 써도 추측 난이도는 별로 오르지 않아요",
"Predictable substitutions like '@' instead of 'a' don't help very much":
"'a' 대신 '@'처럼 뻔한 치환은 큰 도움이 안 돼요",
};
// English: identity map for all known strings (the library already returns
// English, but we wrap them so unknown values still pass through unchanged).
const warningsEn: Record<string, string> = Object.fromEntries(
@@ -77,8 +126,14 @@ export interface ZxcvbnTranslations {
}
export function getZxcvbnTranslations(locale: Locale): ZxcvbnTranslations {
const warnings = locale === "de" ? warningsDe : warningsEn;
const suggestions = locale === "de" ? suggestionsDe : suggestionsEn;
const warnings =
locale === "de" ? warningsDe : locale === "ko" ? warningsKo : warningsEn;
const suggestions =
locale === "de"
? suggestionsDe
: locale === "ko"
? suggestionsKo
: suggestionsEn;
return {
translate(text: string): string {
return warnings[text] ?? suggestions[text] ?? text;
+2
View File
@@ -17,6 +17,7 @@ import esMessages from "./messages/es.json";
import frMessages from "./messages/fr.json";
import itMessages from "./messages/it.json";
import plMessages from "./messages/pl.json";
import koMessages from "./messages/ko.json";
primeMessages("de", deMessages);
primeMessages("en", enMessages);
@@ -24,3 +25,4 @@ primeMessages("es", esMessages);
primeMessages("fr", frMessages);
primeMessages("it", itMessages);
primeMessages("pl", plMessages);
primeMessages("ko", koMessages);