|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `/i18n` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-2). |
| 5 | + * Serves translations / locales / labels from whatever provides the `i18n` |
| 6 | + * service slot: I18nServicePlugin (service-i18n) when installed, or the |
| 7 | + * AppPlugin in-memory fallback auto-registered for stacks that declare |
| 8 | + * translation bundles — multi-provider slot, so route registration stays |
| 9 | + * dispatcher-owned (moving it into one provider would 404 the other). |
| 10 | + * |
| 11 | + * Routes (path is the sub-path after `/i18n`): |
| 12 | + * GET /locales → getLocales |
| 13 | + * GET /translations/:locale → getTranslations (locale from path) |
| 14 | + * GET /translations?locale=xx → getTranslations (locale from query) |
| 15 | + * GET /labels/:object/:locale → getFieldLabels (both from path) |
| 16 | + * GET /labels/:object?locale=xx → getFieldLabels (locale from query) |
| 17 | + */ |
| 18 | + |
| 19 | +import { resolveLocale } from '@objectstack/core'; |
| 20 | +import { CoreServiceName } from '@objectstack/spec/system'; |
| 21 | +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; |
| 22 | +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; |
| 23 | + |
| 24 | +export function createI18nDomain(deps: DomainHandlerDeps): DomainRoute { |
| 25 | + return { |
| 26 | + prefix: '/i18n', |
| 27 | + handler: (req, context) => |
| 28 | + handleI18nRequest(deps, req.path.substring(5), req.method, req.query, context), |
| 29 | + }; |
| 30 | +} |
| 31 | + |
| 32 | +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleI18n`. */ |
| 33 | +export async function handleI18nRequest( |
| 34 | + deps: DomainHandlerDeps, |
| 35 | + path: string, |
| 36 | + method: string, |
| 37 | + query: any, |
| 38 | + _context: HttpProtocolContext, |
| 39 | +): Promise<HttpDispatcherResult> { |
| 40 | + const i18nService = await deps.getService(CoreServiceName.enum.i18n); |
| 41 | + if (!i18nService) return { handled: true, response: deps.error('i18n service not available', 501) }; |
| 42 | + |
| 43 | + const m = method.toUpperCase(); |
| 44 | + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); |
| 45 | + |
| 46 | + if (m !== 'GET') return { handled: false }; |
| 47 | + |
| 48 | + // GET /i18n/locales |
| 49 | + if (parts[0] === 'locales' && parts.length === 1) { |
| 50 | + const locales = i18nService.getLocales(); |
| 51 | + return { handled: true, response: deps.success({ locales }) }; |
| 52 | + } |
| 53 | + |
| 54 | + // GET /i18n/translations/:locale OR /i18n/translations?locale=xx |
| 55 | + if (parts[0] === 'translations') { |
| 56 | + const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale; |
| 57 | + if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; |
| 58 | + |
| 59 | + let translations = i18nService.getTranslations(locale); |
| 60 | + |
| 61 | + // Locale fallback: try resolving to an available locale when |
| 62 | + // the exact code yields empty translations (e.g. zh → zh-CN). |
| 63 | + if (Object.keys(translations).length === 0) { |
| 64 | + const availableLocales = typeof i18nService.getLocales === 'function' |
| 65 | + ? i18nService.getLocales() : []; |
| 66 | + const resolved = resolveLocale(locale, availableLocales); |
| 67 | + if (resolved && resolved !== locale) { |
| 68 | + translations = i18nService.getTranslations(resolved); |
| 69 | + return { handled: true, response: deps.success({ locale: resolved, requestedLocale: locale, translations }) }; |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + return { handled: true, response: deps.success({ locale, translations }) }; |
| 74 | + } |
| 75 | + |
| 76 | + // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx |
| 77 | + if (parts[0] === 'labels' && parts.length >= 2) { |
| 78 | + const objectName = decodeURIComponent(parts[1]); |
| 79 | + let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; |
| 80 | + if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; |
| 81 | + |
| 82 | + // Locale fallback for labels endpoint |
| 83 | + const availableLocales = typeof i18nService.getLocales === 'function' |
| 84 | + ? i18nService.getLocales() : []; |
| 85 | + const resolved = resolveLocale(locale, availableLocales); |
| 86 | + if (resolved) locale = resolved; |
| 87 | + |
| 88 | + if (typeof i18nService.getFieldLabels === 'function') { |
| 89 | + const labels = i18nService.getFieldLabels(objectName, locale); |
| 90 | + return { handled: true, response: deps.success({ object: objectName, locale, labels }) }; |
| 91 | + } |
| 92 | + // Fallback: derive field labels from full translation bundle |
| 93 | + const translations = i18nService.getTranslations(locale); |
| 94 | + const prefix = `o.${objectName}.fields.`; |
| 95 | + const labels: Record<string, string> = {}; |
| 96 | + for (const [key, value] of Object.entries(translations)) { |
| 97 | + if (key.startsWith(prefix)) { |
| 98 | + labels[key.substring(prefix.length)] = value as string; |
| 99 | + } |
| 100 | + } |
| 101 | + return { handled: true, response: deps.success({ object: objectName, locale, labels }) }; |
| 102 | + } |
| 103 | + |
| 104 | + return { handled: false }; |
| 105 | +} |
0 commit comments