i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
This commit is contained in:
@@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
|
||||
import { loadEnv } from '../shared/env.js'
|
||||
import { t, localeDict } from './i18n.js'
|
||||
import { indexRouter } from './routes/index.js'
|
||||
import { opRouter } from './routes/op.js'
|
||||
|
||||
@@ -23,6 +24,14 @@ app.set('trust proxy', 1)
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
|
||||
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
|
||||
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
|
||||
app.use((_req, res, next) => {
|
||||
res.locals.t = t
|
||||
res.locals.localeDict = localeDict
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||
resave: false,
|
||||
@@ -104,8 +113,8 @@ app.use('/', opRouter)
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err)
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
res.status(500).send(`서버 오류: ${message}`)
|
||||
const message = err instanceof Error ? err.message : t('errors.unknown')
|
||||
res.status(500).send(t('errors.serverError', { message }))
|
||||
})
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
|
||||
6
src/server/i18n.ts
Normal file
6
src/server/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
|
||||
const i18n = loadComponentI18n('server')
|
||||
export const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
@@ -16,6 +16,7 @@ import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import type { PackDefinition, PackList } from '../../shared/types.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
@@ -46,7 +47,7 @@ opRouter.post('/op', async (req, res, next) => {
|
||||
const accounts = await readAccounts()
|
||||
const matched = accounts.find((entry) => entry.password === password)
|
||||
if (!matched) {
|
||||
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
|
||||
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
||||
return
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
@@ -106,7 +107,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const releases = await fetchReleaseVersions()
|
||||
@@ -142,7 +143,7 @@ opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
@@ -163,7 +164,7 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
|
||||
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
|
||||
return
|
||||
}
|
||||
const normalized = normalizePackList(req.body)
|
||||
@@ -179,13 +180,13 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
|
||||
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
|
||||
const url = pickFirstValue(req.body?.url).trim()
|
||||
if (!url) {
|
||||
res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
|
||||
res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const entry = await fetchVideoMeta(url)
|
||||
if (!entry) {
|
||||
res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
|
||||
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, entry })
|
||||
@@ -203,7 +204,7 @@ opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) =>
|
||||
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||
const url = pickFirstValue(req.body?.url).trim()
|
||||
if (!url) {
|
||||
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
|
||||
res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -238,19 +239,27 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.')
|
||||
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
const lines: string[] = []
|
||||
lines.push(`# === musicquiz: ${definition.name} ===`)
|
||||
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}장`)
|
||||
lines.push(`say [musicquiz] 데이터팩 초기화`)
|
||||
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
|
||||
lines.push(t('datapackOutput.header', { name: definition.name }))
|
||||
lines.push(t('datapackOutput.summary', {
|
||||
musicCount: list.music.length,
|
||||
imageCount: list.images.length
|
||||
}))
|
||||
lines.push(t('datapackOutput.initLine'))
|
||||
lines.push(t('datapackOutput.placeholder'))
|
||||
list.music.forEach((entry, index) => {
|
||||
const title = entry.title || '(제목 없음)'
|
||||
const artist = entry.artist || '(가수 미상)'
|
||||
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
|
||||
const title = entry.title || t('datapackOutput.titleFallback')
|
||||
const artist = entry.artist || t('datapackOutput.artistFallback')
|
||||
lines.push(t('datapackOutput.trackLine', {
|
||||
index: index + 1,
|
||||
title,
|
||||
artist,
|
||||
duration: entry.durationSec
|
||||
}))
|
||||
})
|
||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||
} catch (error) {
|
||||
@@ -287,7 +296,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
|
||||
const normalized = normalizePackDefinition(partial)
|
||||
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
||||
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
|
||||
res.status(400).send(t('errors.ramOrderInvalid'))
|
||||
return
|
||||
}
|
||||
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { t } from './i18n.js'
|
||||
|
||||
export interface YtPlaylistEntry {
|
||||
id: string
|
||||
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)')
|
||||
super(message || t('youtube.ytdlpUnavailable'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ export async function ensureYtDlp(): Promise<string> {
|
||||
// 검증
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
||||
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
||||
}
|
||||
return target
|
||||
} catch (err) {
|
||||
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise<string> {
|
||||
throw err instanceof YtDlpUnavailableError
|
||||
? err
|
||||
: new YtDlpUnavailableError(
|
||||
'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err))
|
||||
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
||||
)
|
||||
} finally {
|
||||
installPromise = null
|
||||
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise<boolean> {
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error('redirect 가 너무 많습니다.'))
|
||||
reject(new Error(t('youtube.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
@@ -161,7 +162,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
|
||||
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
||||
return
|
||||
}
|
||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||
@@ -208,7 +209,7 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
|
||||
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
||||
return
|
||||
}
|
||||
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||
|
||||
93
src/shared/i18n.ts
Normal file
93
src/shared/i18n.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
|
||||
* { step1: { title: '1단계. 음악퀴즈 선택' } }
|
||||
* t('step1.title') → '1단계. 음악퀴즈 선택'
|
||||
*/
|
||||
export type Locale = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
|
||||
*
|
||||
* 사용 패턴:
|
||||
* const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
|
||||
* t('step1.title')
|
||||
* t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
|
||||
*
|
||||
* 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
|
||||
* 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
|
||||
*/
|
||||
export interface I18n {
|
||||
/** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
|
||||
t(key: string, params?: Record<string, string | number>): string
|
||||
/** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
|
||||
dict: Locale
|
||||
}
|
||||
|
||||
export function createI18n(filePath: string): I18n {
|
||||
let dict: Locale = {}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
dict = JSON.parse(raw) as Locale
|
||||
} catch {
|
||||
// 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
|
||||
dict = {}
|
||||
}
|
||||
|
||||
function lookup(key: string): string | undefined {
|
||||
const parts = key.split('.')
|
||||
let cur: unknown = dict
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[p]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return typeof cur === 'string' ? cur : undefined
|
||||
}
|
||||
|
||||
function interpolate(tpl: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
|
||||
return name in params ? String(params[name]) : `{{${name}}}`
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
t(key, params) {
|
||||
const found = lookup(key)
|
||||
return interpolate(found ?? key, params)
|
||||
},
|
||||
dict
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
|
||||
* `locales/<component>/ko-kr.json` 을 찾아 로드.
|
||||
*
|
||||
* 탐색 순서(처음 발견된 것만 사용):
|
||||
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
|
||||
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
|
||||
*/
|
||||
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
|
||||
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
|
||||
const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
|
||||
const candidates: string[] = []
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||
candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
|
||||
}
|
||||
candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
return createI18n(p)
|
||||
}
|
||||
}
|
||||
return createI18n(candidates[candidates.length - 1] ?? '')
|
||||
}
|
||||
Reference in New Issue
Block a user