mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add minimal i18next i18n foundation (#5943)
## Thinking Path
> - Paperclip orchestrates AI-agent companies through a web control
plane.
> - The UI currently renders operator-facing copy directly from React
components.
> - Internationalization needs a smallest-possible starting point before
broader locale work can proceed.
> - The package declarations for `i18next` and `react-i18next` landed
separately, so this PR can stay focused on the implementation slice.
> - The implementation keeps the first surface English-only and
deliberately tiny while using the established `i18next` +
`react-i18next` runtime.
> - Future language contributions should be able to add a single locale
JSON file, with validation guarding key shape, interpolation parity,
suspicious payloads, and string length.
> - Locale strings must remain display-only UI copy and must not flow
into prompts, agent instructions, tool calls, shell commands, issue
content, approvals, adapter config, or other LLM-visible control paths.
## What Changed
- Initialized `i18next` behind the existing `@/i18n` boundary with fixed
English resources, fallback English, no detector plugin, no backend
plugin, no language picker, and no rich-text translation component.
- Kept `ui/src/i18n/locales/en.json` as the English source locale and
converted the validated JSON locale registry into i18next resources
before app rendering.
- Routed the no-companies start page title, description, and button
through `t(key, { defaultValue })` while preserving unchanged rendered
English copy.
- Added locale validation and focused Vitest coverage for missing/extra
keys, non-string leaves, interpolation parity, suspicious
executable/link payloads, and length caps.
- Addressed Greptile i18n review feedback: case-insensitive
event-handler detection, multi-violation diagnostics,
future-locale-friendly registration test, surfaced i18next init errors,
and removed the redundant side-effect import.
- Rebasing note: rebased onto current `public-gh/master` after the
package-only PR landed; this PR no longer changes `ui/package.json` or
`pnpm-lock.yaml`.
## Verification
- `pnpm install --no-lockfile --ignore-scripts` to install local
dependencies without reading or writing `pnpm-lock.yaml`.
- `pnpm --filter @paperclipai/ui exec vitest run
src/i18n/locale-validation.test.ts` -> passed, 7 tests.
- `pnpm --filter @paperclipai/ui typecheck` -> passed.
- `git diff --name-only public-gh/master...HEAD` shows only i18n
implementation files and the touched App copy call site; no package
manifest or lockfile changes remain in this PR.
- Visual impact is intentionally unchanged for the touched no-companies
copy because the English translations match the previous literal
strings.
## Risks
- Locale validation reduces prompt-injection risk, but the main safety
invariant is architectural: locale strings must remain display-only and
must never be used as LLM-visible control text.
- This intentionally does not add non-English locales, a language
picker, browser detection, HTTP/backend locale loading, server
localization, adapter localization, broad copy migration, or new package
scripts.
- Repository-wide CI may still depend on the separate lockfile-refresh
workflow for the already-merged package declaration, but this PR no
longer introduces package manifest or lockfile changes itself.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning
mode.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why screenshots are not applicable because
there is no intended visual change
- [x] I have updated relevant documentation to reflect my changes, or
confirmed no docs changed because behavior/commands did not change
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
afb73ba553
commit
e2d7263b07
6 changed files with 308 additions and 3 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "@/i18n";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||||
|
|
@ -245,16 +246,21 @@ function UnprefixedBoardRedirect() {
|
||||||
|
|
||||||
function NoCompaniesStartPage() {
|
function NoCompaniesStartPage() {
|
||||||
const { openOnboarding } = useDialogActions();
|
const { openOnboarding } = useDialogActions();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
<div className="rounded-lg border border-border bg-card p-6">
|
<div className="rounded-lg border border-border bg-card p-6">
|
||||||
<h1 className="text-xl font-semibold">Create your first company</h1>
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t("app.noCompanies.title", { defaultValue: "Create your first company" })}
|
||||||
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Get started by creating a company.
|
{t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button onClick={() => openOnboarding()}>New Company</Button>
|
<Button onClick={() => openOnboarding()}>
|
||||||
|
{t("app.noCompanies.newCompany", { defaultValue: "New Company" })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
26
ui/src/i18n/index.ts
Normal file
26
ui/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import i18n, { type InitOptions, type TOptions } from "i18next";
|
||||||
|
import { initReactI18next, useTranslation as useReactI18nextTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, i18nextResources, supportedLocales } from "./locales";
|
||||||
|
|
||||||
|
const i18nextOptions: InitOptions = {
|
||||||
|
resources: i18nextResources,
|
||||||
|
lng: DEFAULT_LOCALE,
|
||||||
|
fallbackLng: DEFAULT_LOCALE,
|
||||||
|
supportedLngs: supportedLocales,
|
||||||
|
defaultNS: "translation",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
returnObjects: false,
|
||||||
|
initAsync: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init(i18nextOptions).catch((error: unknown) => {
|
||||||
|
console.error("Failed to initialize i18next", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function t(key: string, options: TOptions = {}) {
|
||||||
|
return i18n.t(key, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTranslation = useReactI18nextTranslation;
|
||||||
|
export { i18n };
|
||||||
102
ui/src/i18n/locale-validation.test.ts
Normal file
102
ui/src/i18n/locale-validation.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { t } from ".";
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
import { localeMessages } from "./locales";
|
||||||
|
import { validateLocaleMessages } from "./locale-validation";
|
||||||
|
|
||||||
|
describe("locale validation", () => {
|
||||||
|
it("resolves English messages with key and default fallbacks", () => {
|
||||||
|
expect(t("app.noCompanies.title")).toBe(en.app.noCompanies.title);
|
||||||
|
expect(t("app.missing", { defaultValue: "Fallback" })).toBe("Fallback");
|
||||||
|
expect(t("app.missing")).toBe("app.missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts registered locale files", () => {
|
||||||
|
expect(Object.keys(localeMessages)).toContain("en");
|
||||||
|
for (const [locale, messages] of Object.entries(localeMessages)) {
|
||||||
|
expect(validateLocaleMessages(messages), locale).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and extra nested keys", () => {
|
||||||
|
expect(
|
||||||
|
validateLocaleMessages({
|
||||||
|
app: {
|
||||||
|
noCompanies: {
|
||||||
|
title: en.app.noCompanies.title,
|
||||||
|
description: en.app.noCompanies.description,
|
||||||
|
unexpected: "Unexpected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"app.noCompanies.newCompany is missing",
|
||||||
|
"app.noCompanies.unexpected is not defined in English",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string leaves", () => {
|
||||||
|
expect(
|
||||||
|
validateLocaleMessages({
|
||||||
|
app: {
|
||||||
|
noCompanies: {
|
||||||
|
...en.app.noCompanies,
|
||||||
|
title: ["Create your first company"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual(expect.arrayContaining(["app.noCompanies.title must be a string"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires interpolation placeholders to match English", () => {
|
||||||
|
const reference = {
|
||||||
|
message: "Invite {{name}} to {{company}}",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateLocaleMessages({ message: "Invite {{name}}" }, reference)).toEqual([
|
||||||
|
'message interpolation placeholders must match English exactly: expected ["company","name"], received ["name"]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects executable, raw HTML, and unexpected link payloads not present in English", () => {
|
||||||
|
const reference = {
|
||||||
|
script: "Create company",
|
||||||
|
handler: "Create company",
|
||||||
|
js: "Create company",
|
||||||
|
data: "Create company",
|
||||||
|
url: "Create company",
|
||||||
|
html: "Create company",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateLocaleMessages(
|
||||||
|
{
|
||||||
|
script: "<script>alert(1)</script>",
|
||||||
|
handler: '<span ONCLICK="alert(1)">Create</span>',
|
||||||
|
js: "javascript:alert(1)",
|
||||||
|
data: "data:text/html,hello",
|
||||||
|
url: "https://example.test",
|
||||||
|
html: "<strong>Create company</strong>",
|
||||||
|
},
|
||||||
|
reference,
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"script contains disallowed <script",
|
||||||
|
"handler contains disallowed event-handler attribute",
|
||||||
|
"js contains disallowed javascript:",
|
||||||
|
"data contains disallowed data:",
|
||||||
|
"url contains disallowed unexpected URL",
|
||||||
|
"html contains disallowed raw HTML tag",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps localized string length relative to English", () => {
|
||||||
|
expect(validateLocaleMessages({ message: "x".repeat(200) }, { message: "Short" })).toEqual([
|
||||||
|
"message is too long: 200 characters exceeds 133",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
ui/src/i18n/locale-validation.ts
Normal file
121
ui/src/i18n/locale-validation.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
|
||||||
|
const MAX_STRING_LENGTH = 2_000;
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPath(path: string[]) {
|
||||||
|
return path.length > 0 ? path.join(".") : "<root>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolationPlaceholders(value: string) {
|
||||||
|
return Array.from(value.matchAll(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g), (match) => match[1]).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRawHtml(value: string) {
|
||||||
|
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?>/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEventHandlerAttribute(value: string) {
|
||||||
|
return /\son[A-Za-z]+\s*=/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlsIn(value: string) {
|
||||||
|
return Array.from(value.matchAll(/\bhttps?:\/\/[^\s<>"')]+/gi), (match) => match[0]).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBlockedData(value: string, englishValue: string) {
|
||||||
|
const checks: Array<[boolean, boolean, string]> = [
|
||||||
|
[/<script\b/i.test(value), /<script\b/i.test(englishValue), "<script"],
|
||||||
|
[hasEventHandlerAttribute(value), hasEventHandlerAttribute(englishValue), "event-handler attribute"],
|
||||||
|
[/\bjavascript\s*:/i.test(value), /\bjavascript\s*:/i.test(englishValue), "javascript:"],
|
||||||
|
[/\bdata\s*:/i.test(value), /\bdata\s*:/i.test(englishValue), "data:"],
|
||||||
|
[hasRawHtml(value), hasRawHtml(englishValue), "raw HTML tag"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocked = checks
|
||||||
|
.filter(([candidateHas, englishHas]) => candidateHas && !englishHas)
|
||||||
|
.map(([, , blockedPayload]) => blockedPayload);
|
||||||
|
|
||||||
|
const englishUrls = new Set(urlsIn(englishValue));
|
||||||
|
const unexpectedUrl = urlsIn(value).find((url) => !englishUrls.has(url));
|
||||||
|
if (unexpectedUrl) blocked.push("unexpected URL");
|
||||||
|
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(path: string[], candidateValue: string, englishValue: string, errors: string[]) {
|
||||||
|
const candidatePlaceholders = interpolationPlaceholders(candidateValue);
|
||||||
|
const englishPlaceholders = interpolationPlaceholders(englishValue);
|
||||||
|
if (candidatePlaceholders.join("\u0000") !== englishPlaceholders.join("\u0000")) {
|
||||||
|
errors.push(
|
||||||
|
`${formatPath(path)} interpolation placeholders must match English exactly: expected ${JSON.stringify(
|
||||||
|
englishPlaceholders,
|
||||||
|
)}, received ${JSON.stringify(candidatePlaceholders)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const blockedPayload of hasBlockedData(candidateValue, englishValue)) {
|
||||||
|
errors.push(`${formatPath(path)} contains disallowed ${blockedPayload}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeLimit = Math.max(englishValue.length * 4 + 64, englishValue.length + 128);
|
||||||
|
const lengthLimit = Math.min(MAX_STRING_LENGTH, relativeLimit);
|
||||||
|
if (candidateValue.length > lengthLimit) {
|
||||||
|
errors.push(`${formatPath(path)} is too long: ${candidateValue.length} characters exceeds ${lengthLimit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNode(path: string[], candidate: unknown, englishReference: unknown, errors: string[]) {
|
||||||
|
if (typeof englishReference === "string") {
|
||||||
|
if (typeof candidate !== "string") {
|
||||||
|
errors.push(`${formatPath(path)} must be a string`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validateString(path, candidate, englishReference, errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(englishReference)) {
|
||||||
|
errors.push(`${formatPath(path)} has unsupported English reference type`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(candidate)) {
|
||||||
|
errors.push(`${formatPath(path)} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const englishKeys = Object.keys(englishReference).sort();
|
||||||
|
const candidateKeys = Object.keys(candidate).sort();
|
||||||
|
const missingKeys = englishKeys.filter((key) => !candidateKeys.includes(key));
|
||||||
|
const extraKeys = candidateKeys.filter((key) => !englishKeys.includes(key));
|
||||||
|
|
||||||
|
for (const key of missingKeys) {
|
||||||
|
errors.push(`${formatPath([...path, key])} is missing`);
|
||||||
|
}
|
||||||
|
for (const key of extraKeys) {
|
||||||
|
errors.push(`${formatPath([...path, key])} is not defined in English`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of englishKeys) {
|
||||||
|
if (key in candidate) {
|
||||||
|
validateNode([...path, key], candidate[key], englishReference[key], errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLocaleMessages(candidate: unknown, englishReference: unknown = en) {
|
||||||
|
const errors: string[] = [];
|
||||||
|
validateNode([], candidate, englishReference, errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidLocaleMessages(candidate: unknown, englishReference: unknown = en) {
|
||||||
|
const errors = validateLocaleMessages(candidate, englishReference);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Invalid locale messages:\n${errors.join("\n")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ui/src/i18n/locales.ts
Normal file
41
ui/src/i18n/locales.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Resource } from "i18next";
|
||||||
|
|
||||||
|
import { assertValidLocaleMessages } from "./locale-validation";
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE = "en" as const;
|
||||||
|
|
||||||
|
const localeModules = import.meta.glob("./locales/*.json", {
|
||||||
|
eager: true,
|
||||||
|
import: "default",
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
export const localeMessages = Object.fromEntries(
|
||||||
|
Object.entries(localeModules).map(([path, messages]) => {
|
||||||
|
const locale = path.match(/\/([A-Za-z0-9_-]+)\.json$/)?.[1];
|
||||||
|
if (!locale) {
|
||||||
|
throw new Error(`Invalid locale file path: ${path}`);
|
||||||
|
}
|
||||||
|
return [locale, messages];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(DEFAULT_LOCALE in localeMessages)) {
|
||||||
|
throw new Error(`Missing default locale messages for ${DEFAULT_LOCALE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, messages] of Object.entries(localeMessages)) {
|
||||||
|
try {
|
||||||
|
assertValidLocaleMessages(messages);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid ${locale} locale messages: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportedLocales = Object.keys(localeMessages);
|
||||||
|
|
||||||
|
export const i18nextResources: Resource = Object.fromEntries(
|
||||||
|
Object.entries(localeMessages).map(([locale, messages]) => [locale, { translation: messages }]),
|
||||||
|
) as Resource;
|
||||||
|
|
||||||
|
export type SupportedLocale = keyof typeof localeMessages;
|
||||||
9
ui/src/i18n/locales/en.json
Normal file
9
ui/src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"noCompanies": {
|
||||||
|
"title": "Create your first company",
|
||||||
|
"description": "Get started by creating a company.",
|
||||||
|
"newCompany": "New Company"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue