Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ and is more lenient, so **a clean pass locally does not prove Node 22**.
bodies stored as markdown text, rendered via `renderMarkdown`.
- Maintenance/preview mode: admin toggle → frontend serves a branded
"we'll be right back" 503; `?preview=<token>` cookie bypasses it.
- **Admin is multilingual** (13 locales, mirrors perry/landing). UI strings
live in bundled per-locale TS bags under `src/admin/i18n/messages/`
(`en.ts` is source of truth, en is the runtime fallback); never hardcode
user-facing chrome — use `t('key')`/`t.plural(...)` from `getT(c)`.
Per-request locale = `users.locale` → `skelpoAdminLang` cookie →
Accept-Language (`src/admin/i18n/middleware.ts`, mounted on `adminRoutes`).
Users set their own language at `/admin/profile` + the sidebar switcher.
Adding a locale = extend `i18n/locales.ts` + add a `messages/<loc>.ts`.

## Permissions

Expand Down
126 changes: 74 additions & 52 deletions src/admin/contentEditor.tsx

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions src/admin/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// UI-string translator for the admin. Messages are bundled per-locale TS
// modules (see ./messages). t() drills a dot-path through the active
// locale's bag and falls back to English for any missing key, so a
// partially-translated locale never shows a blank — worst case it shows
// English. Synchronous: bags are static imports, no DB round-trip (keeps
// the Perry-native AOT path and the login page fast).

import type { Context } from 'hono';
import { type AdminLocale, defaultAdminLocale } from './locales.js';
import { bags } from './messages/index.js';

/** A plural message carries a singular + general form, chosen by count. */
export interface PluralValue {
one: string;
other: string;
}

export type MessageNode = string | PluralValue | MessageBag;
export interface MessageBag {
[key: string]: MessageNode;
}

export type Vars = Record<string, string | number>;

export interface Translator {
/** Translate a dot-path key, interpolating `{name}` placeholders. */
(key: string, vars?: Vars): string;
/** Translate a plural key (expects `{ one, other }`); `{n}` is injected. */
plural: (key: string, count: number, vars?: Vars) => string;
/** The active locale this translator is bound to. */
locale: AdminLocale;
}

function isPlural(v: MessageNode): v is PluralValue {
return (
typeof v === 'object' &&
v !== null &&
typeof (v as PluralValue).one === 'string' &&
typeof (v as PluralValue).other === 'string'
);
}

/** Walk a dot-path through a bag, returning the node at the path or null. */
function drill(bag: MessageBag, key: string): MessageNode | null {
const parts = key.split('.');
let cur: MessageNode = bag;
for (const p of parts) {
if (cur && typeof cur === 'object' && !isPlural(cur) && p in cur) {
cur = (cur as MessageBag)[p]!;
} else {
return null;
}
}
return cur ?? null;
}

function interpolate(s: string, vars?: Vars): string {
if (!vars) return s;
return s.replace(/\{(\w+)\}/g, (m, name: string) =>
name in vars ? String(vars[name]) : m,
);
}

/** Build a translator bound to a locale, with English as the fallback. */
export function makeT(locale: AdminLocale): Translator {
const local = bags[locale] ?? bags[defaultAdminLocale];
const fallback = bags[defaultAdminLocale];

const resolve = (key: string): MessageNode | null =>
drill(local, key) ?? drill(fallback, key);

const t = ((key: string, vars?: Vars): string => {
const v = resolve(key);
if (typeof v === 'string') return interpolate(v, vars);
// A plural node used without count: prefer `other`. Anything else
// (object/null) means a missing key — surface the key itself.
if (v && isPlural(v)) return interpolate(v.other, vars);
return key;
}) as Translator;

t.plural = (key: string, count: number, vars?: Vars): string => {
const v = resolve(key);
const merged: Vars = { n: count, ...vars };
if (v && isPlural(v)) {
// English-style selection (n === 1 → singular). Good enough for the
// 13 admin locales; languages without a plural set both forms equal.
return interpolate(count === 1 ? v.one : v.other, merged);
}
if (typeof v === 'string') return interpolate(v, merged);
return key;
};

t.locale = locale;
return t;
}

/** Get the request's translator (set by attachAdminI18n), or an English
* fallback for contexts where the middleware didn't run. */
export function getT(c: Context): Translator {
return c.get('t') ?? makeT(defaultAdminLocale);
}
87 changes: 87 additions & 0 deletions src/admin/i18n/locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Admin UI locales. Mirrors the 13 languages shipped by the perry/landing
// marketing site (src/i18n/routing.ts there) so the CMS admin and the
// customer site speak the same set. This is product chrome, not customer
// content — the list is fixed and bundled into the binary.

export const adminLocales = [
'en',
'de',
'es',
'fr',
'it',
'ja',
'ko',
'pt',
'th',
'tr',
'vi',
'id',
'zh-Hans',
] as const;

export type AdminLocale = (typeof adminLocales)[number];

export const defaultAdminLocale: AdminLocale = 'en';

// Endonyms (each language's name in its own script) — what we show in the
// language picker, matching perry/landing's localeNames.
export const adminLocaleNames: Record<AdminLocale, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
pt: 'Português',
th: 'ไทย',
tr: 'Türkçe',
vi: 'Tiếng Việt',
id: 'Indonesia',
'zh-Hans': '中文',
};

const localeSet = new Set<string>(adminLocales);

export function isAdminLocale(v: unknown): v is AdminLocale {
return typeof v === 'string' && localeSet.has(v);
}

/** Coerce an arbitrary locale-ish string to a supported locale or null.
* Handles exact matches, base-language matches (`de-AT` → `de`) and the
* Chinese script tag (`zh`, `zh-CN` → `zh-Hans`). */
export function coerceLocale(v: string | null | undefined): AdminLocale | null {
if (!v) return null;
const raw = v.trim();
if (isAdminLocale(raw)) return raw;
const lower = raw.toLowerCase();
if (lower === 'zh' || lower.startsWith('zh-') || lower.startsWith('zh_')) {
return 'zh-Hans';
}
const base = lower.split(/[-_]/)[0]!;
if (isAdminLocale(base)) return base;
return null;
}

/** Pick the best supported locale from an Accept-Language header. Falls
* back to the default when nothing matches. */
export function negotiateLocale(acceptLanguage: string | null | undefined): AdminLocale {
if (!acceptLanguage) return defaultAdminLocale;
const ranked = acceptLanguage
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const q = params
.map((p) => p.trim())
.find((p) => p.startsWith('q='));
const weight = q ? Number(q.slice(2)) : 1;
return { tag: (tag ?? '').trim(), weight: Number.isFinite(weight) ? weight : 1 };
})
.filter((r) => r.tag.length > 0)
.sort((a, b) => b.weight - a.weight);
for (const { tag } of ranked) {
const hit = coerceLocale(tag);
if (hit) return hit;
}
return defaultAdminLocale;
}
Loading
Loading