feat: add internationalization message platform

This commit is contained in:
donghyeon-ka
2026-07-26 16:17:39 +09:00
parent b8c0444217
commit 668bf05b48
57 changed files with 1711 additions and 201 deletions
@@ -1,3 +1,5 @@
import { formatMessage } from "../i18n/index.js";
/**
* @param {{
* kind?: string,
@@ -18,32 +20,36 @@ export function BootErrorShell({
}) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
<dl>
<dt>오류</dt>
<dt>{formatMessage("ko-KR", "boot.field.error")}</dt>
<dd>{kind}</dd>
<dt>코드</dt>
<dt>{formatMessage("ko-KR", "boot.field.code")}</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>빌드</dt>
<dt>{formatMessage("ko-KR", "boot.field.build")}</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>설정 스키마</dt>
<dt>{formatMessage("ko-KR", "boot.field.configSchema")}</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>릴리스</dt>
<dt>{formatMessage("ko-KR", "boot.field.release")}</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>지원 참조: {supportReference}</p>
<p>
{formatMessage("ko-KR", "boot.supportReference", {
reference: supportReference,
})}
</p>
</main>
);
}
@@ -3,6 +3,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { useLocale } from "../i18n/index.js";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
@@ -71,27 +72,44 @@ export class ChunkRecoveryBoundary extends Component<Props, State> {
const { error, recovery, reason } = this.state;
if (error && !isChunkLoadFailure(error)) throw error;
if (error && recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
.
</section>
);
return <ChunkRecoverySurface recovery="checking" />;
}
if (error && recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
.
</section>
);
return <ChunkRecoverySurface recovery="reload-requested" />;
}
if (error && recovery === "support") {
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1> .</h1>
<p> .</p>
</section>
);
return <ChunkRecoverySurface recovery="support" reason={reason} />;
}
return this.props.children;
}
}
function ChunkRecoverySurface({
recovery,
reason,
}: Readonly<{
recovery: Exclude<State["recovery"], "idle">;
reason?: string;
}>) {
const { message } = useLocale();
if (recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
{message("chunk.checking")}
</section>
);
}
if (recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
{message("chunk.reloadOnce")}
</section>
);
}
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1>{message("chunk.failure.title")}</h1>
<p>{message("chunk.failure.description")}</p>
</section>
);
}
@@ -1,4 +1,5 @@
import { Component } from "react";
import { formatMessage } from "../i18n/index.js";
/**
* @typedef {{
@@ -57,9 +58,9 @@ export class RenderErrorBoundary extends Component {
return (
this.props.fallback ?? (
<section role="alert">
<p>error.render_failure</p>
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}>
retry
{formatMessage("ko-KR", "action.retry")}
</button>
</section>
)
+32 -14
View File
@@ -2,19 +2,22 @@ import { useId } from "react";
import { errorMessage } from "./error-copy.js";
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
export function LoadingSurface({ label }) {
const { message } = useLocale();
const accessibleLabel = label ?? message("async.loading");
return (
<section
className="state-surface state-surface--loading"
aria-busy="true"
aria-label={label}
aria-label={accessibleLabel}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="visually-hidden">{label}</span>
<span className="visually-hidden">{accessibleLabel}</span>
</section>
);
}
@@ -27,13 +30,14 @@ export function LoadingSurface({ label = "불러오는 중" }) {
* }} props
*/
export function EmptySurface({
title = "표시할 항목이 없습니다.",
title,
description,
action,
}) {
const { message } = useLocale();
return (
<section className="ui-empty state-surface" aria-live="polite">
<h2>{title}</h2>
<h2>{title ?? message("async.empty")}</h2>
{description ? <p>{description}</p> : null}
{action}
</section>
@@ -49,13 +53,14 @@ export function EmptySurface({
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
const { locale, message } = useLocale();
const messageId = useId();
const actionLabels = Object.freeze({
retry: "다시 시도",
reauth: "로그인",
navigate: "안전한 화면으로 이동",
"reload-once": "한 번 새로고침",
"contact-support": "지원 정보 확인",
retry: message("action.retry"),
reauth: message("action.reauth"),
navigate: message("action.navigateSafe"),
"reload-once": message("action.reloadOnce"),
"contact-support": message("action.contactSupport"),
});
return (
<section
@@ -64,7 +69,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
aria-labelledby={messageId}
data-message-key={userMessageKey}
>
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
<h2 id={messageId}>{errorMessage(userMessageKey, locale)}</h2>
{action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button>
)}
@@ -88,6 +93,7 @@ export function AsyncSurface({
onRetry,
onResolveConflict,
}) {
const { message } = useLocale();
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
@@ -104,12 +110,24 @@ export function AsyncSurface({
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator ? (
<div role="status" aria-live="polite">
<span>{state.indicator}</span>
<span>
{message(
state.indicator === "stale-degraded"
? "async.staleDegraded"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
)}
</span>
{state.indicator === "stale-degraded" && onRetry ? (
<Button onClick={onRetry}>다시 시도</Button>
<Button onClick={onRetry}>{message("action.retry")}</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
<Button onClick={onResolveConflict}>
{message("action.resolveConflict")}
</Button>
) : null}
</div>
) : null}
+4 -24
View File
@@ -1,26 +1,6 @@
const ERROR_MESSAGES = Object.freeze({
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
});
import { resolveMessage } from "../i18n/index.js";
/** @param {string} messageKey */
export function errorMessage(messageKey) {
const messages = /** @type {Readonly<Record<string, string>>} */ (
ERROR_MESSAGES
);
return messages[messageKey] ?? "요청을 완료하지 못했습니다.";
/** @param {string} messageKey @param {string} [locale] */
export function errorMessage(messageKey, locale = "ko-KR") {
return resolveMessage(locale, messageKey);
}
+16 -12
View File
@@ -1,4 +1,5 @@
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/**
* @param {{
@@ -30,12 +31,13 @@ function StateSurface({
/** @param {{ onSignIn?: () => void }} props */
export function AuthRequiredSurface({ onSignIn }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="401 · 인증 필요"
title="로그인이 필요합니다."
description="세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다."
actionLabel="로그인 시작"
eyebrow={message("access.auth.eyebrow")}
title={message("access.auth.title")}
description={message("access.auth.description")}
actionLabel={message("action.signIn")}
onAction={onSignIn}
/>
);
@@ -43,12 +45,13 @@ export function AuthRequiredSurface({ onSignIn }) {
/** @param {{ onNavigate?: () => void }} props */
export function ForbiddenSurface({ onNavigate }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="403 · 권한 없음"
title="접근할 수 없습니다."
description="권한을 확인하거나 접근 가능한 화면으로 이동하세요."
actionLabel="안전한 화면으로 이동"
eyebrow={message("access.forbidden.eyebrow")}
title={message("access.forbidden.title")}
description={message("access.forbidden.description")}
actionLabel={message("action.navigateSafe")}
onAction={onNavigate}
tone="warning"
/>
@@ -57,12 +60,13 @@ export function ForbiddenSurface({ onNavigate }) {
/** @param {{ onNavigate?: () => void }} props */
export function NotFoundSurface({ onNavigate }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="404 · 찾을 수 없음"
title="요청한 화면이 없습니다."
description="주소를 확인하거나 시작 화면으로 돌아가세요."
actionLabel="시작 화면으로 이동"
eyebrow={message("access.notFound.eyebrow")}
title={message("access.notFound.title")}
description={message("access.notFound.description")}
actionLabel={message("action.goHome")}
onAction={onNavigate}
/>
);
@@ -8,6 +8,7 @@ import {
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
import { useLocale } from "../../i18n/index.js";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
@@ -277,6 +278,7 @@ export function Alert({
dismissLabel,
onDismiss,
}: AlertProps) {
const { message } = useLocale();
return (
<section
className={`ui-alert ui-alert--${variant}`}
@@ -288,7 +290,9 @@ export function Alert({
</div>
{onDismiss ? (
<IconButton
accessibleName={dismissLabel ?? `${title} 알림 닫기`}
accessibleName={
dismissLabel ?? message("action.alertCloseNamed", { title })
}
className="ui-alert__dismiss"
onClick={onDismiss}
variant="ghost"
@@ -334,6 +338,7 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
},
forwardedRef,
) {
const { message } = useLocale();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const titleId = useId();
@@ -364,8 +369,11 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
queueMicrotask(() => {
if (previousFocus?.isConnected) previousFocus.focus();
});
}, [open]);
return (
@@ -395,7 +403,9 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<IconButton
accessibleName={closeLabel ?? `${title} 닫기`}
accessibleName={
closeLabel ?? message("action.closeNamed", { title })
}
className="ui-dialog__close"
onClick={onClose}
variant="ghost"
@@ -9,6 +9,7 @@ import {
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
import { Field, IconButton } from "./core.js";
import type { TextFieldProps } from "./core.js";
import { useLocale } from "../../i18n/index.js";
type FieldCopy = Readonly<{
id?: string;
@@ -41,6 +42,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
},
ref,
) {
const { message } = useLocale();
const [uncontrolledValue, setUncontrolledValue] = useState(
String(defaultValue ?? ""),
);
@@ -80,7 +82,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
<output className="ui-field__counter">
{maxLengthMessage
? maxLengthMessage(remaining)
: `${remaining}자 남음`}
: message("form.remaining", { count: remaining })}
</output>
) : null}
</>
@@ -1,5 +1,6 @@
import { useId, useRef, useState } from "react";
import { useLocale } from "../../i18n/index.js";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
import { IconButton, LinkButton } from "./core.js";
@@ -62,6 +63,7 @@ export function Tabs({
const selected = value ?? internalValue;
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const baseId = useId();
const { direction } = useLocale();
function select(id: string) {
if (value === undefined) setInternalValue(id);
@@ -98,10 +100,10 @@ export function Tabs({
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(index, 1);
move(index, direction === "rtl" ? -1 : 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, -1);
move(index, direction === "rtl" ? 1 : -1);
} else if (
activation === "manual" &&
(event.key === "Enter" || event.key === " ")
@@ -156,6 +158,10 @@ export function Pagination({
onChange(page: number): void;
}>) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
const { direction } = useLocale();
const PreviousDirectionalIcon =
direction === "rtl" ? NextIcon : PreviousIcon;
const NextDirectionalIcon = direction === "rtl" ? PreviousIcon : NextIcon;
return (
<nav aria-label={label} className="ui-pagination">
<IconButton
@@ -164,7 +170,7 @@ export function Pagination({
onClick={() => onChange(page - 1)}
variant="secondary"
>
<PreviousIcon />
<PreviousDirectionalIcon />
</IconButton>
{pages.map((candidate) => (
<LinkButton
@@ -186,7 +192,7 @@ export function Pagination({
onClick={() => onChange(page + 1)}
variant="secondary"
>
<NextIcon />
<NextDirectionalIcon />
</IconButton>
</nav>
);
@@ -11,6 +11,7 @@ import {
import { CloseIcon } from "../icons/semantic-icons.js";
import { Button, Dialog, IconButton } from "./core.js";
import { useLocale } from "../../i18n/index.js";
export type DrawerProps = Readonly<{
open: boolean;
@@ -343,9 +344,10 @@ function ToastRegion({
toasts: readonly ToastEntry[];
dismiss(id: string): void;
}>) {
const { message } = useLocale();
return (
<section
aria-label="Notifications"
aria-label={message("toast.region")}
aria-live="polite"
className="ui-toast-region"
>
@@ -363,6 +365,7 @@ function ToastItem({
toast: ToastEntry;
dismiss(id: string): void;
}>) {
const { message } = useLocale();
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
@@ -391,7 +394,7 @@ function ToastItem({
{toast.description ? <p>{toast.description}</p> : null}
</div>
<IconButton
accessibleName={`${toast.title} 닫기`}
accessibleName={message("action.closeNamed", { title: toast.title })}
onClick={() => dismiss(toast.id)}
variant="ghost"
>
+3 -1
View File
@@ -1,6 +1,7 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import { useLocale } from "../i18n/index.js";
import type {
FieldErrors,
FieldName,
@@ -31,6 +32,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const { message } = useLocale();
const {
fieldErrors,
formErrors = [],
@@ -50,7 +52,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}> .</h2>
<h2 id={headingId}>{message("form.errorSummary")}</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
+24 -12
View File
@@ -1,4 +1,8 @@
import type { ApiFailure } from "../../contracts/errors.js";
import {
formatMessage,
type ParameterlessMessageKey,
} from "../i18n/index.js";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
@@ -23,24 +27,32 @@ export type MappedValidationFailure<Values extends FormValues> = Readonly<{
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "필수 입력값입니다.",
too_small: "입력값이 너무 짧습니다.",
too_big: "입력값이 너무 깁니다.",
invalid_type: "입력 형식을 확인해 주세요.",
invalid_format: "입력 형식을 확인해 주세요.",
invalid_value: "허용된 값을 선택해 주세요.",
REQUIRED: "form.validation.required",
too_small: "form.validation.tooSmall",
too_big: "form.validation.tooBig",
invalid_type: "form.validation.invalidType",
invalid_format: "form.validation.invalidFormat",
invalid_value: "form.validation.invalidValue",
});
export function validationMessage(code: string): string {
return (
type MessageResolver = (key: ParameterlessMessageKey) => string;
const defaultMessage: MessageResolver = (key) => formatMessage("ko-KR", key);
export function validationMessage(
code: string,
message: MessageResolver = defaultMessage,
): string {
return message(
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"입력값을 확인해 주세요."
"form.validation.unknown",
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
allowedFields: readonly FieldName<Values>[],
message: MessageResolver = defaultMessage,
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
@@ -51,15 +63,15 @@ export function mapValidationFailureToFields<Values extends FormValues>(
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push("입력값을 다시 확인해 주세요.");
formErrors.push(message("form.validation.retry"));
}
for (const issue of issues) {
const field = issue.path.split(".").at(0) ?? "";
if (allowed.has(field)) {
const name = field as FieldName<Values>;
fieldErrors[name] ??= validationMessage(issue.code);
fieldErrors[name] ??= validationMessage(issue.code, message);
} else {
formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
formErrors.push(message("form.validation.unknownField"));
}
}
return Object.freeze({
+13 -4
View File
@@ -8,6 +8,10 @@ import {
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
useLocale,
type ParameterlessMessageKey,
} from "../i18n/index.js";
import {
mapValidationFailureToFields,
@@ -37,6 +41,7 @@ export function useAppForm<
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const { message } = useLocale();
const {
schema,
defaultValues,
@@ -142,13 +147,14 @@ export function useAppForm<
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
message,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? ["입력 구성을 다시 확인해 주세요."]
? [message("form.validation.configuration")]
: [],
);
setTouched(new Set(allowedServerFields));
@@ -178,6 +184,7 @@ export function useAppForm<
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
message,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
@@ -185,11 +192,11 @@ export function useAppForm<
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
message("form.conflict"),
]);
setResult("conflict");
} else {
setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
setFormErrors([message("form.unavailable")]);
setResult("unavailable");
}
return outcome;
@@ -203,6 +210,7 @@ export function useAppForm<
defaultValues,
focusFirstError,
mapToCommand,
message,
resetOnSuccess,
schema,
submit,
@@ -231,6 +239,7 @@ export function useAppForm<
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
message: (key: ParameterlessMessageKey) => string,
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
@@ -238,7 +247,7 @@ function issuesToFieldErrors<Values extends FormValues>(
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code);
errors[name] ??= validationMessage(issue.code, message);
}
return Object.freeze(errors);
}
@@ -3,6 +3,7 @@ import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { useLocale } from "../i18n/index.js";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
@@ -33,19 +34,20 @@ export function useDirtyNavigationGuard(when: boolean) {
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
const { message } = useLocale();
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title="저장하지 않은 변경이 있습니다."
description="이 화면을 떠나면 입력한 내용이 사라집니다."
title={message("form.unsaved.title")}
description={message("form.unsaved.description")}
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
{message("action.continueEditing")}
</Button>
<Button variant="danger" onClick={props.guard.leave}>
{message("action.discardAndLeave")}
</Button>
</>
}
+304
View File
@@ -0,0 +1,304 @@
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js";
const PLATFORM_KO_MESSAGES = {
"common.unavailable": "요청한 문구를 표시할 수 없습니다.",
"common.processing": "처리 중…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "표시 정보 없음",
"action.retry": "다시 시도",
"action.reauth": "로그인",
"action.navigateSafe": "안전한 화면으로 이동",
"action.reloadOnce": "한 번 새로고침",
"action.contactSupport": "지원 정보 확인",
"action.resolveConflict": "충돌 해결",
"action.continueEditing": "계속 작성",
"action.discardAndLeave": "변경 버리고 이동",
"action.signIn": "로그인 시작",
"action.recoverSession": "세션 복구",
"action.signOut": "로그아웃",
"action.goHome": "홈으로 이동",
"action.closeNamed": "{title} 닫기",
"action.alertCloseNamed": "{title} 알림 닫기",
"shell.skipToContent": "본문으로 건너뛰기",
"shell.menu": "메뉴",
"shell.closeMenu": "메뉴 닫기",
"shell.sidebar": "사이드바",
"shell.primaryNavigation": "주요 탐색",
"shell.theme": "색상 테마",
"shell.theme.system": "시스템 테마",
"shell.theme.light": "라이트 테마",
"shell.theme.dark": "다크 테마",
"shell.locale": "언어",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "인증됨",
"shell.session.unauthenticated": "로그인 전",
"shell.session.recoveryPending": "복구 대기",
"shell.session.integrationFailed": "연동 필요",
"shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.",
"route.APP_HOME.navigation": "홈",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_UI.navigation": "UI 구성요소",
"route.EXAMPLES_UI.title": "UI 구성요소",
"route.EXAMPLES_STATES.navigation": "화면 상태",
"route.EXAMPLES_STATES.title": "화면 상태",
"route.EXAMPLES_AUTH.navigation": "인증 연동",
"route.EXAMPLES_AUTH.title": "인증 연동",
"route.NOT_FOUND.navigation": "찾을 수 없음",
"route.NOT_FOUND.title": "페이지를 찾을 수 없습니다.",
"async.loading": "불러오는 중",
"async.empty": "표시할 항목이 없습니다.",
"async.refreshing": "최신 정보를 확인하고 있습니다.",
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
"async.mutationConflict": "다른 변경과 충돌했습니다.",
"access.auth.eyebrow": "401 · 인증 필요",
"access.auth.title": "로그인이 필요합니다.",
"access.auth.description":
"세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다.",
"access.forbidden.eyebrow": "403 · 권한 없음",
"access.forbidden.title": "접근할 수 없습니다.",
"access.forbidden.description":
"권한을 확인하거나 접근 가능한 화면으로 이동하세요.",
"access.notFound.eyebrow": "404 · 찾을 수 없음",
"access.notFound.title": "요청한 화면이 없습니다.",
"access.notFound.description":
"주소를 확인하거나 시작 화면으로 돌아가세요.",
"route.loading": "화면을 준비하고 있습니다.",
"route.loadingNamed": "{title} 로딩 중",
"route.failure.title": "화면을 표시하지 못했습니다.",
"route.failure.description":
"잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요.",
"route.invalid.title": "올바르지 않은 주소입니다.",
"route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.",
"route.invalid.action": "안전한 탐색 링크를 사용해 주세요.",
"route.auth.integration.title": "로그인 연동이 필요합니다.",
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.required.title": "세션이 필요합니다.",
"route.auth.recovering.description":
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
"route.auth.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
"chunk.failure.title": "화면 자산을 복구하지 못했습니다.",
"chunk.failure.description":
"문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.",
"page.notFound.title": "페이지를 찾을 수 없습니다.",
"page.notFound.description":
"주소를 확인하거나 준비된 시작 화면으로 돌아가세요.",
"template.breadcrumb": "현재 위치",
"template.relatedInformation": "관련 정보",
"template.searchAndFilter": "검색과 필터",
"template.pagination": "페이지 탐색",
"template.summary": "요약 정보",
"template.dangerActions": "위험 작업",
"template.supportReference": "지원 참조: {reference}",
"form.errorSummary": "입력 내용을 확인해 주세요.",
"form.unsaved.title": "저장하지 않은 변경이 있습니다.",
"form.unsaved.description": "이 화면을 떠나면 입력한 내용이 사라집니다.",
"form.validation.required": "필수 입력값입니다.",
"form.validation.tooSmall": "입력값이 너무 짧습니다.",
"form.validation.tooBig": "입력값이 너무 깁니다.",
"form.validation.invalidType": "입력 형식을 확인해 주세요.",
"form.validation.invalidFormat": "입력 형식을 확인해 주세요.",
"form.validation.invalidValue": "허용된 값을 선택해 주세요.",
"form.validation.unknown": "입력값을 확인해 주세요.",
"form.validation.retry": "입력값을 다시 확인해 주세요.",
"form.validation.unknownField":
"서버가 확인하지 못한 입력 항목이 있습니다.",
"form.validation.configuration": "입력 구성을 다시 확인해 주세요.",
"form.conflict":
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
"form.unavailable": "저장하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"form.remaining": "{count}자 남음",
"toast.region": "알림",
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
"boot.field.error": "오류",
"boot.field.code": "코드",
"boot.field.build": "빌드",
"boot.field.configSchema": "설정 스키마",
"boot.field.release": "릴리스",
"boot.supportReference": "지원 참조: {reference}",
} as const;
export const KO_MESSAGES = {
...PLATFORM_KO_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["ko-KR"],
} as const;
export type MessageKey = keyof typeof KO_MESSAGES;
const PLATFORM_EN_MESSAGES = {
"common.unavailable": "This message is unavailable.",
"common.processing": "Processing…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "No display value",
"action.retry": "Try again",
"action.reauth": "Sign in",
"action.navigateSafe": "Go to an available page",
"action.reloadOnce": "Reload once",
"action.contactSupport": "View support information",
"action.resolveConflict": "Resolve conflict",
"action.continueEditing": "Continue editing",
"action.discardAndLeave": "Discard and leave",
"action.signIn": "Start sign-in",
"action.recoverSession": "Recover session",
"action.signOut": "Sign out",
"action.goHome": "Go home",
"action.closeNamed": "Close {title}",
"action.alertCloseNamed": "Dismiss {title} alert",
"shell.skipToContent": "Skip to content",
"shell.menu": "Menu",
"shell.closeMenu": "Close menu",
"shell.sidebar": "Sidebar",
"shell.primaryNavigation": "Primary navigation",
"shell.theme": "Color theme",
"shell.theme.system": "System theme",
"shell.theme.light": "Light theme",
"shell.theme.dark": "Dark theme",
"shell.locale": "Language",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "Authenticated",
"shell.session.unauthenticated": "Signed out",
"shell.session.recoveryPending": "Recovery pending",
"shell.session.integrationFailed": "Integration required",
"shell.session.actionFailed": "The session action could not be completed.",
"route.APP_HOME.navigation": "Home",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_UI.navigation": "UI components",
"route.EXAMPLES_UI.title": "UI components",
"route.EXAMPLES_STATES.navigation": "Screen states",
"route.EXAMPLES_STATES.title": "Screen states",
"route.EXAMPLES_AUTH.navigation": "Authentication",
"route.EXAMPLES_AUTH.title": "Authentication",
"route.NOT_FOUND.navigation": "Not found",
"route.NOT_FOUND.title": "Page not found",
"async.loading": "Loading",
"async.empty": "There are no items to display.",
"async.refreshing": "Checking for the latest information.",
"async.staleDegraded": "Showing previously loaded information.",
"async.mutationPending": "Saving changes.",
"async.mutationConflict": "The change conflicts with another update.",
"access.auth.eyebrow": "401 · Authentication required",
"access.auth.title": "Sign-in is required.",
"access.auth.description":
"Start a session to safely continue the previous task.",
"access.forbidden.eyebrow": "403 · Access denied",
"access.forbidden.title": "You cannot access this page.",
"access.forbidden.description":
"Check your permissions or go to an available page.",
"access.notFound.eyebrow": "404 · Not found",
"access.notFound.title": "The requested page does not exist.",
"access.notFound.description": "Check the address or return to the start page.",
"route.loading": "Preparing the page.",
"route.loadingNamed": "Loading {title}",
"route.failure.title": "The page could not be displayed.",
"route.failure.description":
"Try again shortly. If the problem continues, check the support reference.",
"route.invalid.title": "This address is invalid.",
"route.invalid.description": "Check the path and search parameters.",
"route.invalid.action": "Use a safe navigation link.",
"route.auth.integration.title": "Sign-in integration is required.",
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.",
"route.auth.required.title": "A session is required.",
"route.auth.recovering.description":
"Run recovery to continue checking the existing session.",
"route.auth.required.description":
"This protected route demonstrates the authentication integration seam.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.",
"chunk.failure.title": "The page assets could not be recovered.",
"chunk.failure.description":
"If the problem continues, check deployment status and support information.",
"page.notFound.title": "Page not found.",
"page.notFound.description": "Check the address or return to the start page.",
"template.breadcrumb": "Current location",
"template.relatedInformation": "Related information",
"template.searchAndFilter": "Search and filters",
"template.pagination": "Pagination",
"template.summary": "Summary information",
"template.dangerActions": "Dangerous actions",
"template.supportReference": "Support reference: {reference}",
"form.errorSummary": "Review the entered information.",
"form.unsaved.title": "You have unsaved changes.",
"form.unsaved.description":
"The entered information will be lost if you leave this page.",
"form.validation.required": "This field is required.",
"form.validation.tooSmall": "This value is too short.",
"form.validation.tooBig": "This value is too long.",
"form.validation.invalidType": "Check the input type.",
"form.validation.invalidFormat": "Check the input format.",
"form.validation.invalidValue": "Choose an allowed value.",
"form.validation.unknown": "Check the entered value.",
"form.validation.retry": "Review the entered values.",
"form.validation.unknownField":
"The server reported an unrecognized input field.",
"form.validation.configuration": "Review the input configuration.",
"form.conflict":
"Another update conflicts with this change. Your input has been preserved.",
"form.unavailable": "The change could not be saved. Try again shortly.",
"form.remaining": "{count} characters remaining",
"toast.region": "Notifications",
"error.network_unreachable": "The network is unavailable.",
"error.request_timeout": "The request timed out.",
"error.auth_required": "Sign in to continue.",
"error.auth_integration_failure": "Sign-in integration is unavailable.",
"error.forbidden": "You are not allowed to perform this action.",
"error.not_found": "The requested item could not be found.",
"error.rate_limited": "Too many requests were made. Try again shortly.",
"error.server_failure": "The request could not be completed.",
"error.chunk_load_failure": "The new page assets could not be loaded.",
"error.build_mismatch": "The active build does not match this page.",
"error.config_mismatch": "The runtime configuration does not match this page.",
"error.api_contract_mismatch": "The API contract does not match this page.",
"error.release_mismatch": "The active release does not match this page.",
"error.asset_mismatch": "The page assets do not match this release.",
"error.render_failure": "The page could not be displayed.",
"error.unknown_failure": "An unexpected problem occurred.",
"boot.failure.title": "The application could not start.",
"boot.field.error": "Error",
"boot.field.code": "Code",
"boot.field.build": "Build",
"boot.field.configSchema": "Configuration schema",
"boot.field.release": "Release",
"boot.supportReference": "Support reference: {reference}",
} as const satisfies Record<keyof typeof PLATFORM_KO_MESSAGES, string>;
export const EN_MESSAGES = {
...PLATFORM_EN_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["en-US"],
} as const satisfies Record<MessageKey, string>;
export const MESSAGE_CATALOGS = Object.freeze({
"ko-KR": KO_MESSAGES,
"en-US": EN_MESSAGES,
"ar-EG": EN_MESSAGES,
});
+96
View File
@@ -0,0 +1,96 @@
import { normalizeLocale, type SupportedLocale } from "./message-contract.js";
const FORMAT_FALLBACK = "—";
export type DateFormatStyle = "short" | "medium" | "long";
export function formatDate(
locale: SupportedLocale,
value: Date | number,
options: Readonly<{
dateStyle?: DateFormatStyle;
timeZone?: string;
}> = {},
): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return FORMAT_FALLBACK;
return new Intl.DateTimeFormat(normalizeLocale(locale), {
dateStyle: options.dateStyle ?? "medium",
timeZone: options.timeZone ?? "UTC",
}).format(date);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatNumber(
locale: SupportedLocale,
value: number,
options: Readonly<{
style?: "decimal" | "percent";
maximumFractionDigits?: number;
}> = {},
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.NumberFormat(normalizeLocale(locale), options).format(value);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatRelativeTime(
locale: SupportedLocale,
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.RelativeTimeFormat(normalizeLocale(locale), {
numeric: "auto",
}).format(value, unit);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatList(
locale: SupportedLocale,
values: readonly string[],
): string {
try {
return new Intl.ListFormat(normalizeLocale(locale), {
style: "long",
type: "conjunction",
}).format(values);
} catch {
return values.join(", ");
}
}
export function selectPlural(
locale: SupportedLocale,
value: number,
choices: Readonly<
Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
>,
): string {
try {
if (!Number.isFinite(value)) return choices.other;
return choices[
new Intl.PluralRules(normalizeLocale(locale)).select(value)
] ?? choices.other;
} catch {
return choices.other;
}
}
export function selectMessage(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string {
return choices[value] ?? choices.other;
}
export { FORMAT_FALLBACK };
+29
View File
@@ -0,0 +1,29 @@
export { LocaleProvider, useLocale } from "./locale-provider.js";
export {
catalogKeys,
fallbackMessage,
formatMessage,
localeDirection,
MESSAGE_KEY_ALIASES,
messagePlaceholders,
normalizeLocale,
resolveMessage,
SUPPORTED_LOCALES,
} from "./message-contract.js";
export type {
MessageArguments,
MessageParameters,
ParameterlessMessageKey,
SupportedLocale,
TextDirection,
} from "./message-contract.js";
export type { MessageKey } from "./catalog.js";
export {
FORMAT_FALLBACK,
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
+118
View File
@@ -0,0 +1,118 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
import {
formatMessage,
localeDirection,
normalizeLocale,
resolveMessage,
type MessageArguments,
type SupportedLocale,
type TextDirection,
} from "./message-contract.js";
import type { MessageKey } from "./catalog.js";
type LocaleContextValue = Readonly<{
locale: SupportedLocale;
direction: TextDirection;
setLocale(locale: SupportedLocale): void;
message<Key extends MessageKey>(
key: Key,
...args: MessageArguments<Key>
): string;
resolve(key: string): string;
date(
value: Date | number,
options?: Parameters<typeof formatDate>[2],
): string;
number(value: number, options?: Parameters<typeof formatNumber>[2]): string;
relativeTime(
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string;
list(values: readonly string[]): string;
plural(
value: number,
choices: Parameters<typeof selectPlural>[2],
): string;
select(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string;
}>;
const DEFAULT_LOCALE = "ko-KR" as const;
const DEFAULT_CONTEXT: LocaleContextValue = Object.freeze({
locale: DEFAULT_LOCALE,
direction: "ltr",
setLocale() {},
message: (key, ...args) => formatMessage(DEFAULT_LOCALE, key, ...args),
resolve: (key) => resolveMessage(DEFAULT_LOCALE, key),
date: (value, options) => formatDate(DEFAULT_LOCALE, value, options),
number: (value, options) => formatNumber(DEFAULT_LOCALE, value, options),
relativeTime: (value, unit) =>
formatRelativeTime(DEFAULT_LOCALE, value, unit),
list: (values) => formatList(DEFAULT_LOCALE, values),
plural: (value, choices) => selectPlural(DEFAULT_LOCALE, value, choices),
select: (value, choices) => selectMessage(value, choices),
});
const LocaleContext = createContext<LocaleContextValue>(DEFAULT_CONTEXT);
export function LocaleProvider({
children,
initialLocale = "ko-KR",
}: Readonly<{
children: React.ReactNode;
initialLocale?: SupportedLocale;
}>) {
const [locale, setLocaleState] = useState<SupportedLocale>(() =>
normalizeLocale(initialLocale),
);
const direction = localeDirection(locale);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = direction;
}, [direction, locale]);
const value = useMemo<LocaleContextValue>(
() => ({
locale,
direction,
setLocale: setLocaleState,
message: (key, ...args) => formatMessage(locale, key, ...args),
resolve: (key) => resolveMessage(locale, key),
date: (dateValue, options) => formatDate(locale, dateValue, options),
number: (numberValue, options) =>
formatNumber(locale, numberValue, options),
relativeTime: (relativeValue, unit) =>
formatRelativeTime(locale, relativeValue, unit),
list: (values) => formatList(locale, values),
plural: (pluralValue, choices) =>
selectPlural(locale, pluralValue, choices),
select: (selectValue, choices) => selectMessage(selectValue, choices),
}),
[direction, locale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale(): LocaleContextValue {
return useContext(LocaleContext);
}
+135
View File
@@ -0,0 +1,135 @@
import {
EN_MESSAGES,
KO_MESSAGES,
MESSAGE_CATALOGS,
type MessageKey,
} from "./catalog.js";
export const SUPPORTED_LOCALES = Object.freeze([
"ko-KR",
"en-US",
"en-XA",
"ar-EG",
] as const);
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export type TextDirection = "ltr" | "rtl";
export type MessageParameters = Readonly<{
"action.closeNamed": { title: string };
"action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string };
"template.supportReference": { reference: string };
"form.remaining": { count: number };
"boot.supportReference": { reference: string };
}>;
export type ParameterlessMessageKey = Exclude<
MessageKey,
keyof MessageParameters
>;
export type MessageArguments<Key extends MessageKey> =
Key extends keyof MessageParameters
? [parameters: MessageParameters[Key]]
: [parameters?: never];
const PLACEHOLDER_PATTERN = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g;
const FALLBACK_LOCALE = "ko-KR" as const;
export const MESSAGE_KEY_ALIASES = Object.freeze({
"action.login": "action.reauth",
"async.pending": "common.processing",
} as const satisfies Readonly<Record<string, MessageKey>>);
export function normalizeLocale(locale: string): SupportedLocale {
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const language = locale.split("-")[0]?.toLowerCase();
if (language === "ko") return "ko-KR";
if (language === "ar") return "ar-EG";
if (language === "en") return "en-US";
return FALLBACK_LOCALE;
}
export function localeDirection(locale: SupportedLocale): TextDirection {
return locale === "ar-EG" ? "rtl" : "ltr";
}
export function messagePlaceholders(template: string): readonly string[] {
return Object.freeze(
[...template.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1] ?? ""),
);
}
export function resolveMessage(
locale: string,
key: string,
parameters?: Readonly<Record<string, string | number>>,
): string {
const normalized = normalizeLocale(locale);
const sourceLocale: keyof typeof MESSAGE_CATALOGS =
normalized === "en-XA" ? "en-US" : normalized;
const catalog =
MESSAGE_CATALOGS[sourceLocale] ??
MESSAGE_CATALOGS[FALLBACK_LOCALE];
const fallback = KO_MESSAGES["common.unavailable"];
const canonicalKey =
MESSAGE_KEY_ALIASES[key as keyof typeof MESSAGE_KEY_ALIASES] ?? key;
const template = catalog[canonicalKey as MessageKey] ?? fallback;
const placeholders = messagePlaceholders(template);
if (
placeholders.some(
(placeholder) =>
parameters?.[placeholder] === undefined ||
parameters?.[placeholder] === null,
)
) {
return fallback;
}
const formatted = template.replace(
PLACEHOLDER_PATTERN,
(_, placeholder: string) => String(parameters?.[placeholder] ?? ""),
);
return normalized === "en-XA" ? pseudoLocalize(formatted) : formatted;
}
export function formatMessage<Key extends MessageKey>(
locale: string,
key: Key,
...args: MessageArguments<Key>
): string {
return resolveMessage(
locale,
key,
args[0] as Readonly<Record<string, string | number>> | undefined,
);
}
function pseudoLocalize(value: string): string {
const expanded = value.replace(/[A-Za-z]/g, (character) => {
const replacements: Readonly<Record<string, string>> = {
a: "á",
e: "é",
i: "í",
o: "ó",
u: "ú",
A: "Á",
E: "É",
I: "Í",
O: "Ó",
U: "Ú",
};
return replacements[character] ?? character;
});
return `${expanded} ···]`;
}
export function catalogKeys(
locale: "ko-KR" | "en-US" | "ar-EG",
): MessageKey[] {
return Object.keys(MESSAGE_CATALOGS[locale]).sort() as MessageKey[];
}
export function fallbackMessage() {
return EN_MESSAGES["common.unavailable"];
}
+50 -23
View File
@@ -12,20 +12,22 @@ import {
MenuIcon,
Select,
} from "../design-system/index.js";
import { useLocale } from "../i18n/index.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
const SESSION_LABELS = Object.freeze({
authenticated: "인증됨",
unauthenticated: "로그인 전",
"recovery-pending": "복구 대기",
"integration-failed": "연동 필요",
const SESSION_MESSAGE_KEYS = Object.freeze({
authenticated: "shell.session.authenticated",
unauthenticated: "shell.session.unauthenticated",
"recovery-pending": "shell.session.recoveryPending",
"integration-failed": "shell.session.integrationFailed",
});
export function AppShell() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const { preference, setPreference } = useTheme();
const { locale, setLocale, message } = useLocale();
const [navigationOpen, setNavigationOpen] = useState(false);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
@@ -56,20 +58,20 @@ export function AppShell() {
const sessionActionLabel =
sessionState === "authenticated"
? "로그아웃"
? message("action.signOut")
: sessionState === "recovery-pending"
? "세션 복구"
: "로그인";
? message("action.recoverSession")
: message("action.reauth");
const integrationAvailable = sessionState !== "integration-failed";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
본문으로 건너뛰기
{message("shell.skipToContent")}
</a>
<header className="app-shell__header">
<IconButton
accessibleName="메뉴"
accessibleName={message("shell.menu")}
className="app-shell__menu-button"
aria-controls="mobile-primary-navigation"
aria-expanded={navigationOpen}
@@ -79,17 +81,17 @@ export function AppShell() {
<MenuIcon />
</IconButton>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton
{message("common.appName")}
</NavLink>
<div className="app-shell__session">
<Select
className="theme-selector"
id="theme-preference"
label="색상 테마"
label={message("shell.theme")}
options={[
{ value: "system", label: "시스템 테마" },
{ value: "light", label: "라이트 테마" },
{ value: "dark", label: "다크 테마" },
{ value: "system", label: message("shell.theme.system") },
{ value: "light", label: message("shell.theme.light") },
{ value: "dark", label: message("shell.theme.dark") },
]}
value={preference}
onChange={(event) =>
@@ -100,8 +102,27 @@ export function AppShell() {
)
}
/>
<Select
className="locale-selector"
id="locale-preference"
label={message("shell.locale")}
options={[
{ value: "ko-KR", label: message("shell.locale.ko") },
{ value: "en-US", label: message("shell.locale.en") },
{ value: "en-XA", label: message("shell.locale.pseudo") },
{ value: "ar-EG", label: message("shell.locale.rtl") },
]}
value={locale}
onChange={(event) =>
setLocale(
/** @type {import("../i18n/index.js").SupportedLocale} */ (
event.currentTarget.value
),
)
}
/>
<span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]}
{message(SESSION_MESSAGE_KEYS[sessionState])}
</span>
{integrationAvailable ? (
<Button
@@ -109,24 +130,29 @@ export function AppShell() {
onClick={() => void runSessionAction()}
size="compact"
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
{sessionActionPending
? message("common.processing")
: sessionActionLabel}
</Button>
) : null}
</div>
{sessionActionFailed ? (
<p className="app-shell__session-error" role="alert">
세션 작업을 완료하지 못했습니다.
{message("shell.session.actionFailed")}
</p>
) : null}
</header>
<aside className="app-shell__sidebar" aria-label="사이드바">
<aside
className="app-shell__sidebar"
aria-label={message("shell.sidebar")}
>
<PrimaryNavigation id="primary-navigation" />
</aside>
<Drawer
closeLabel="메뉴 닫기"
closeLabel={message("shell.closeMenu")}
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
title="메뉴"
title={message("shell.menu")}
>
<PrimaryNavigation id="mobile-primary-navigation" />
</Drawer>
@@ -141,8 +167,9 @@ export function AppShell() {
* @param {{ id: string }} props
*/
function PrimaryNavigation({ id }) {
const { resolve, message } = useLocale();
return (
<nav id={id} aria-label="주요 탐색">
<nav id={id} aria-label={message("shell.primaryNavigation")}>
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
@@ -153,7 +180,7 @@ function PrimaryNavigation({ id }) {
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
{resolve(`route.${definition.routeId}.navigation`)}
</NavLink>
</li>
))}
+5 -3
View File
@@ -2,17 +2,19 @@ import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../design-system/index.js";
import { useLocale } from "../i18n/index.js";
export default function NotFoundPage() {
const { message } = useLocale();
return (
<section className="ui-page">
<PageHeader
title="페이지를 찾을 수 없습니다."
description="주소를 확인하거나 준비된 시작 화면으로 돌아가세요."
title={message("page.notFound.title")}
description={message("page.notFound.description")}
/>
<div>
<Link className="ui-button" to={routePath("APP_HOME")}>
홈으로 이동
{message("action.goHome")}
</Link>
</div>
</section>
+46 -26
View File
@@ -28,7 +28,8 @@ import {
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { PageHeader } from "../design-system/index.js";
import { Button, PageHeader } from "../design-system/index.js";
import { LocaleProvider, useLocale } from "../i18n/index.js";
import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
@@ -54,6 +55,8 @@ export function useRouteInput(): ParsedRouteInput {
}
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
const { message, resolve } = useLocale();
const title = resolve(`route.${definition.routeId}.title`);
return (
<section
className="ui-page route-loading"
@@ -62,8 +65,10 @@ function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
data-surface={definition.loadingSurface}
>
<div className="ui-skeleton" aria-hidden="true" />
<p> .</p>
<span className="visually-hidden">{definition.title} </span>
<p>{message("route.loading")}</p>
<span className="visually-hidden">
{message("route.loadingNamed", { title })}
</span>
</section>
);
}
@@ -73,35 +78,41 @@ function RouteFailureSurface({
}: {
definition?: RouteDefinition;
}) {
const { message } = useLocale();
return (
<section
className="ui-page"
data-surface={definition?.errorSurface ?? "route-boundary"}
>
<PageHeader
title="화면을 표시하지 못했습니다."
description="잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
title={message("route.failure.title")}
description={message("route.failure.description")}
/>
</section>
);
}
function InvalidRouteSurface({ code }: { code: string }) {
const { message } = useLocale();
return (
<section className="ui-page" data-surface="invalid-route">
<PageHeader
title="올바르지 않은 주소입니다."
description="주소의 경로 또는 검색 조건을 확인해 주세요."
title={message("route.invalid.title")}
description={message("route.invalid.description")}
/>
<p data-route-error={code}> .</p>
<p data-route-error={code}>{message("route.invalid.action")}</p>
</section>
);
}
function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
const location = useLocation();
const { message, resolve } = useLocale();
useEffect(() => {
document.title = `${definition.title} · Frontend Skeleton`;
document.title = message("route.documentTitle", {
title: resolve(`route.${definition.routeId}.title`),
appName: message("common.appName"),
});
const main = document.getElementById("main-content");
main?.focus({ preventScroll: true });
try {
@@ -111,7 +122,7 @@ function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
} catch {
// Non-browser test hosts may not implement scrolling.
}
}, [definition, location.key, location.pathname]);
}, [definition, location.key, location.pathname, message, resolve]);
return null;
}
@@ -146,6 +157,7 @@ function ProtectedRoute({
}) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const { message } = useLocale();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
@@ -173,8 +185,8 @@ function ProtectedRoute({
return (
<section className="ui-page" data-surface="auth-integration-required">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다."
title={message("route.auth.integration.title")}
description={message("route.auth.integration.description")}
/>
</section>
);
@@ -183,24 +195,30 @@ function ProtectedRoute({
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<button
className="ui-button"
type="button"
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending ? "처리 중…" : recovering ? "세션 복구" : "로그인 시작"}
</button>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
.
{message("shell.session.actionFailed")}
</p>
) : null}
</section>
@@ -300,10 +318,12 @@ export function AppRouter({
[basename, buildId],
);
return (
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
);
}
+14 -3
View File
@@ -131,11 +131,13 @@
margin-inline-start: auto;
}
.theme-selector {
.theme-selector,
.locale-selector {
display: block;
}
.theme-selector .ui-field__label {
.theme-selector .ui-field__label,
.locale-selector .ui-field__label {
position: absolute;
width: 1px;
height: 1px;
@@ -147,13 +149,18 @@
border: 0;
}
.theme-selector .ui-field__input {
.theme-selector .ui-field__input,
.locale-selector .ui-field__input {
min-height: var(--size-control-sm);
max-width: 8.5rem;
padding: 0.35rem 2rem 0.35rem 0.65rem;
cursor: pointer;
}
.locale-selector .ui-field__input {
max-width: 7rem;
}
.app-shell__session-error {
position: absolute;
top: calc(100% + 0.25rem);
@@ -1340,6 +1347,10 @@
display: none;
}
.app-shell__session .theme-selector {
display: none;
}
.app-shell__brand {
display: none;
}
+29 -7
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Button } from "../components/ui/button.jsx";
import { useLocale } from "../i18n/index.js";
export type PageHeading = Readonly<{
title: string;
@@ -36,10 +37,14 @@ export type PageTemplateSlots = Readonly<{
}>;
export function StandardPage(props: PageTemplateSlots) {
const { message } = useLocale();
return (
<article className="ui-page page-template page-template--standard">
{props.breadcrumb ? (
<nav className="page-template__breadcrumb" aria-label="현재 위치">
<nav
className="page-template__breadcrumb"
aria-label={message("template.breadcrumb")}
>
{props.breadcrumb}
</nav>
) : null}
@@ -64,7 +69,10 @@ export function StandardPage(props: PageTemplateSlots) {
>
<div className="page-template__content">{props.children}</div>
{props.aside ? (
<aside className="page-template__aside" aria-label="관련 정보">
<aside
className="page-template__aside"
aria-label={message("template.relatedInformation")}
>
{props.aside}
</aside>
) : null}
@@ -83,6 +91,7 @@ export function CollectionPage(
pagination?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage
{...props}
@@ -92,7 +101,7 @@ export function CollectionPage(
{props.toolbar ? (
<section
className="collection-page__toolbar"
aria-label="검색과 필터"
aria-label={message("template.searchAndFilter")}
>
{props.toolbar}
</section>
@@ -117,7 +126,10 @@ export function CollectionPage(
>
<div className="collection-page__results">{props.children}</div>
{props.pagination ? (
<nav className="collection-page__pagination" aria-label="페이지 탐색">
<nav
className="collection-page__pagination"
aria-label={message("template.pagination")}
>
{props.pagination}
</nav>
) : null}
@@ -132,16 +144,23 @@ export function DetailPage(
destructiveAction?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage {...props}>
{props.metadata ? (
<section className="detail-page__metadata" aria-label="요약 정보">
<section
className="detail-page__metadata"
aria-label={message("template.summary")}
>
{props.metadata}
</section>
) : null}
<div className="detail-page__sections">{props.children}</div>
{props.destructiveAction ? (
<section className="detail-page__danger" aria-label="위험 작업">
<section
className="detail-page__danger"
aria-label={message("template.dangerActions")}
>
{props.destructiveAction}
</section>
) : null}
@@ -190,6 +209,7 @@ export function StatusPage(
supportReference?: string;
}>,
) {
const { message } = useLocale();
return (
<section
className={`ui-page page-template status-page status-page--${props.variant}`}
@@ -208,7 +228,9 @@ export function StatusPage(
) : null}
{props.supportReference ? (
<p className="status-page__support">
: <code>{props.supportReference}</code>
{message("template.supportReference", {
reference: props.supportReference,
})}
</p>
) : null}
</section>