Add Korean Japanese and English user interfaces

This commit is contained in:
2026-10-15 18:21:17 +09:00
parent 8fb8c203f0
commit b831003e7d
41 changed files with 3074 additions and 351 deletions

View File

@@ -1,5 +1,13 @@
import { z } from 'zod';
export { classifyClipboard, clipboardCharacterCount } from './clipboard.js';
export {
SUPPORTED_LANGUAGES,
normalizeLanguage,
preferredLanguage,
initialLanguage,
translate,
translateSystemMessage,
} from './localization.js';
export const DEVICE_NAME_MAX_LENGTH = 40;
export const PASSWORD_MIN_LENGTH = 8;
export const devicePatch = z
@@ -116,7 +124,13 @@ export type Space = {
expiresAt: number;
members: { device: Device; status: 'pending' | 'approved' }[];
};
export type SessionResponse = { device: Device; csrfToken: string; expiresAt: number };
export type Language = 'ko' | 'ja' | 'en';
export type SessionResponse = {
device: Device;
csrfToken: string;
expiresAt: number;
defaultLanguage?: Language;
};
export function parseUrls(value: string): { urls: string[]; invalid: string[] } {
const entries = value
.split(/\r?\n/)

View File

@@ -0,0 +1,69 @@
import { messages } from './messages.js';
export const SUPPORTED_LANGUAGES = ['ko', 'ja', 'en'];
export function normalizeLanguage(value) {
const language = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_]/)[0] : '';
return SUPPORTED_LANGUAGES.includes(language) ? language : null;
}
export function preferredLanguage(header, fallback = 'en') {
if (typeof header !== 'string') return fallback;
const choices = header
.slice(0, 1024)
.split(',')
.map((part, order) => {
const [tag, ...parameters] = part.trim().split(';');
const quality = parameters.find((parameter) => parameter.trim().startsWith('q='));
const weight = quality ? Number(quality.trim().slice(2)) : 1;
return { language: normalizeLanguage(tag), weight, order };
});
return (
choices
.filter(({ language, weight }) => language && weight > 0 && weight <= 1)
.sort((a, b) => b.weight - a.weight || a.order - b.order)[0]?.language ?? fallback
);
}
export function initialLanguage(country, browserLanguages) {
if (country === 'KR') return 'ko';
if (country === 'JP') return 'ja';
if (country && /^[A-Z]{2}$/.test(country) && country !== 'XX') return 'en';
return preferredLanguage(browserLanguages);
}
const interpolate = (message, values) =>
message.replace(/\{(p\d+)\}/g, (match, key) =>
Object.hasOwn(values, key) ? String(values[key] ?? '') : match,
);
export function translate(message, language = 'ko', values = {}) {
const text = language === 'ko' ? message : (messages[message]?.[language] ?? message);
return interpolate(text, values);
}
const sourceMessages = new Map(
Object.entries(messages).flatMap(([key, translations]) =>
[key, ...Object.values(translations)].map((text) => [text, key]),
),
);
const patterns = Object.keys(messages)
.filter((key) => /\{p\d+\}/.test(key))
.flatMap((key) =>
[key, ...Object.values(messages[key])].map((text) => {
const names = [...text.matchAll(/\{(p\d+)\}/g)].map((match) => match[1]);
const escaped = text
.split(/\{p\d+\}/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { key, names, pattern: new RegExp('^' + escaped.join('([\\s\\S]*?)') + '$') };
}),
);
// Apply only to system messages, never filenames, device names or transferred text.
export function translateSystemMessage(message, language) {
const source = sourceMessages.get(message);
if (source) return translate(source, language);
for (const { key, names, pattern } of patterns) {
const match = pattern.exec(message);
if (match)
return translate(
key,
language,
Object.fromEntries(names.map((name, i) => [name, match[i + 1]])),
);
}
return message;
}

File diff suppressed because it is too large Load Diff