[READY] fix(i18n): Add more missed translations + fixes + cleanup (#1381)

* Add more missed translations for all locales

* Add missing translations

* Remove unused keys

* Sort translation keys alphabetically

* Extra polish

* Fix broken translations

* Last fix

* Fix dangling references

* More translations, improve audit script

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johnny Shields
2026-04-09 02:44:18 +09:00
committed by GitHub
parent da5d972a54
commit 46fa5a22cb
20 changed files with 9922 additions and 8271 deletions

View File

@@ -9,7 +9,7 @@ import {
import { Folder, FolderLock, FolderSearch, X } from "lucide-solid";
import { currentLocale, t } from "../../i18n";
import { t } from "../../i18n";
import Button from "../components/button";
import type {
OpenworkServerCapabilities,
@@ -87,7 +87,9 @@ const readAuthorizedFoldersFromConfig = (opencodeConfig: Record<string, unknown>
const buildAuthorizedFoldersStatus = (preservedCount: number, action?: string) => {
const preservedLabel =
preservedCount > 0
? `Preserving ${preservedCount} non-folder permission ${preservedCount === 1 ? "entry" : "entries"}.`
? preservedCount === 1
? t("context_panel.preserving_entry")
: t("context_panel.preserving_entries", undefined, { count: preservedCount })
: null;
if (action && preservedLabel) return `${action} ${preservedLabel}`;
return action ?? preservedLabel;
@@ -133,13 +135,13 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
(props.openworkServerCapabilities?.config?.write ?? false),
);
const authorizedFoldersHint = createMemo(() => {
if (!openworkServerReady()) return "OpenWork server is disconnected.";
if (!openworkServerWorkspaceReady()) return "No active server workspace is selected.";
if (!openworkServerReady()) return t("context_panel.server_disconnected");
if (!openworkServerWorkspaceReady()) return t("context_panel.no_server_workspace");
if (!canReadConfig()) {
return "OpenWork server config access is unavailable for this workspace.";
return t("context_panel.config_access_unavailable");
}
if (!canWriteConfig()) {
return "OpenWork server is connected read-only for workspace config.";
return t("context_panel.config_read_only");
}
return null;
});
@@ -206,14 +208,14 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
const openworkWorkspaceId = props.runtimeWorkspaceId;
if (!openworkClient || !openworkWorkspaceId || !canWriteConfig()) {
setAuthorizedFoldersError(
"A writable OpenWork server workspace is required to update authorized folders.",
t("context_panel.writable_workspace_required"),
);
return false;
}
setAuthorizedFoldersSaving(true);
setAuthorizedFoldersError(null);
setAuthorizedFoldersStatus("Saving authorized folders...");
setAuthorizedFoldersStatus(t("context_panel.saving_folders"));
try {
const currentConfig = await openworkClient.getConfig(openworkWorkspaceId);
@@ -236,7 +238,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
setAuthorizedFoldersStatus(
buildAuthorizedFoldersStatus(
Object.keys(currentAuthorizedFolders.hiddenEntries).length,
"Authorized folders updated.",
t("context_panel.folders_updated"),
),
);
props.onConfigUpdated();
@@ -257,13 +259,13 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
if (!normalized) return;
if (workspaceRoot && normalized === workspaceRoot) {
setAuthorizedFolderDraft("");
setAuthorizedFoldersStatus("Workspace root is already available.");
setAuthorizedFoldersStatus(t("context_panel.workspace_root_available"));
setAuthorizedFoldersError(null);
return;
}
if (authorizedFolders().includes(normalized)) {
setAuthorizedFolderDraft("");
setAuthorizedFoldersStatus("Folder is already authorized.");
setAuthorizedFoldersStatus(t("context_panel.folder_already_authorized"));
setAuthorizedFoldersError(null);
return;
}
@@ -283,7 +285,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
if (!isTauriRuntime()) return;
try {
const selection = await pickDirectory({
title: t("onboarding.authorize_folder", currentLocale()),
title: t("onboarding.authorize_folder"),
});
const folder =
typeof selection === "string"
@@ -297,12 +299,12 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
setAuthorizedFolderDraft(normalized);
if (workspaceRoot && normalized === workspaceRoot) {
setAuthorizedFolderDraft("");
setAuthorizedFoldersStatus("Workspace root is already available.");
setAuthorizedFoldersStatus(t("context_panel.workspace_root_available"));
setAuthorizedFoldersError(null);
return;
}
if (authorizedFolders().includes(normalized)) {
setAuthorizedFoldersStatus("Folder is already authorized.");
setAuthorizedFoldersStatus(t("context_panel.folder_already_authorized"));
setAuthorizedFoldersError(null);
return;
}
@@ -321,10 +323,10 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-gray-12">
<FolderLock size={16} class="text-gray-10" />
Authorized folders
{t("context_panel.authorized_folders")}
</div>
<div class="text-xs text-gray-9 leading-relaxed max-w-[65ch]">
Grant this workspace access to read and edit files in directories outside of its root.
{t("context_panel.authorized_folders_desc")}
</div>
</div>
@@ -333,7 +335,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
fallback={
<div class={`${softPanelClass} px-3 py-3 text-xs text-gray-10`}>
{authorizedFoldersHint() ??
"Connect to a writable OpenWork server workspace to edit authorized folders."}
t("context_panel.authorized_folders_no_access")}
</div>
}
>
@@ -353,9 +355,9 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-3/30 text-blue-11 mb-3">
<Folder size={20} />
</div>
<div class="text-sm font-medium text-gray-11">No external folders authorized</div>
<div class="text-sm font-medium text-gray-11">{t("context_panel.no_external_folders")}</div>
<div class="text-[11px] text-gray-9 mt-1 max-w-[40ch]">
Add a folder to let this workspace read and edit files outside its root directory.
{t("context_panel.add_folder_hint")}
</div>
</div>
}
@@ -380,7 +382,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
<span class="truncate text-sm font-medium text-gray-12">{folderName}</span>
<Show when={isWorkspaceRoot}>
<span class="rounded-full border border-blue-7/30 bg-blue-3/25 px-2 py-0.5 text-[10px] font-medium text-blue-11">
Workspace root
{t("context_panel.workspace_root_badge")}
</span>
</Show>
</div>
@@ -391,7 +393,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
when={!isWorkspaceRoot}
fallback={
<span class="shrink-0 text-[10px] font-medium text-gray-8">
Always available
{t("context_panel.always_available")}
</span>
}
>
@@ -404,7 +406,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
authorizedFoldersSaving() ||
!canWriteConfig()
}
aria-label={`Remove ${folderName}`}
aria-label={t("context_panel.remove_folder", undefined, { name: folderName })}
>
<X size={16} class="text-current" />
</Button>
@@ -446,7 +448,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
onPaste={(event) => {
event.preventDefault();
}}
placeholder="Type a folder path to authorize..."
placeholder={t("context_panel.input_placeholder")}
disabled={
authorizedFoldersLoading() ||
authorizedFoldersSaving() ||
@@ -467,7 +469,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
!canWriteConfig()
}
>
<FolderSearch size={13} class="mr-1.5" /> Browse
<FolderSearch size={13} class="mr-1.5" /> {t("context_panel.browse_button")}
</Button>
</Show>
@@ -482,7 +484,7 @@ export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProp
!authorizedFolderDraft().trim()
}
>
{authorizedFoldersSaving() ? "Adding..." : "Add"}
{authorizedFoldersSaving() ? t("context_panel.adding_button") : t("context_panel.add_button")}
</Button>
</form>
</div>

View File

@@ -799,7 +799,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
if (!result.ok) {
throw new Error(result.message);
}
setStatusMessage(`${result.message} ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${result.message} ${t("den.reload_workspace")}`);
} catch (error) {
setSkillHubActionError(error instanceof Error ? error.message : `Failed to import ${hub.name}.`);
} finally {
@@ -821,7 +821,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
if (!result.ok) {
throw new Error(result.message);
}
setStatusMessage(`${result.message} ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${result.message} ${t("den.reload_workspace")}`);
} catch (error) {
setSkillHubActionError(error instanceof Error ? error.message : `Failed to remove ${imported.name}.`);
} finally {
@@ -843,7 +843,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
if (!result.ok) {
throw new Error(result.message);
}
setStatusMessage(`${result.message} ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${result.message} ${t("den.reload_workspace")}`);
} catch (error) {
setSkillHubActionError(error instanceof Error ? error.message : `Failed to sync ${hub.name}.`);
} finally {
@@ -861,9 +861,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
try {
const message = await props.connectCloudProvider(cloudProviderId);
setStatusMessage(`${message || `Imported ${providerName}.`} ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${message || t("den.imported_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`);
} catch (error) {
setProviderActionError(error instanceof Error ? error.message : `Failed to import ${providerName}.`);
setProviderActionError(error instanceof Error ? error.message : t("den.import_provider_failed", undefined, { name: providerName }));
} finally {
setProviderActionId(null);
setProviderActionKind(null);
@@ -879,9 +879,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
try {
const message = await props.removeCloudProvider(cloudProviderId);
setStatusMessage(`${message || `Removed ${providerName}.`} ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${message || t("den.removed_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`);
} catch (error) {
setProviderActionError(error instanceof Error ? error.message : `Failed to remove ${providerName}.`);
setProviderActionError(error instanceof Error ? error.message : t("den.remove_provider_failed", undefined, { name: providerName }));
} finally {
setProviderActionId(null);
setProviderActionKind(null);
@@ -897,9 +897,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
try {
await props.connectCloudProvider(cloudProviderId);
setStatusMessage(`Synced ${providerName}. ${t("reload.toast_description", currentLocale())}`);
setStatusMessage(`${t("den.synced_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`);
} catch (error) {
setProviderActionError(error instanceof Error ? error.message : `Failed to sync ${providerName}.`);
setProviderActionError(error instanceof Error ? error.message : t("den.sync_provider_failed", undefined, { name: providerName }));
} finally {
setProviderActionId(null);
setProviderActionKind(null);

View File

@@ -1,7 +1,7 @@
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
import { CheckCircle2, Circle, Search, X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";
import { t } from "../../i18n";
import Button from "./button";
import ProviderIcon from "./provider-icon";
@@ -24,7 +24,7 @@ export type ModelPickerModalProps = {
export default function ModelPickerModal(props: ModelPickerModalProps) {
let searchInputRef: HTMLInputElement | undefined;
const translate = (key: string) => t(key, currentLocale());
const translate = (key: string, params?: Record<string, string | number>) => t(key, undefined, params);
type RenderedItem =
| { kind: "model"; opt: ModelOption }
@@ -306,9 +306,9 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<span class="truncate">{provider.title}</span>
</div>
<div class={`mt-0.5 flex items-center gap-3 text-[11px] ${index === activeIndex() ? 'text-gray-10' : 'text-gray-9 group-hover:text-gray-10'}`}>
<span class="truncate">Connect this provider to browse and save models</span>
<span class="truncate">{translate("model_picker.connect_provider_hint")}</span>
<span class="ml-auto opacity-70">
{provider.matchCount} {provider.matchCount === 1 ? "model" : "models"}
{translate(provider.matchCount === 1 ? "model_picker.model_count_one" : "model_picker.model_count", { count: provider.matchCount })}
</span>
</div>
</div>
@@ -324,12 +324,12 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">
{props.target === "default" ? "Default model" : "Chat model"}
{translate(props.target === "default" ? "model_picker.default_model_title" : "model_picker.chat_model_title")}
</h3>
<p class="text-sm text-gray-11 mt-1">
{props.target === "default"
? "Choose the default model for new chats, then fine-tune reasoning profiles on its card before pressing Done."
: "Choose the model for this chat. If a model supports reasoning profiles, configure them on its card."}
{translate(props.target === "default"
? "model_picker.default_model_desc"
: "model_picker.chat_model_desc")}
</p>
</div>
<Button
@@ -355,7 +355,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
</div>
<Show when={props.query.trim()}>
<div class="mt-2 text-xs text-dls-secondary">
{translate("settings.showing_models").replace("{count}", String(props.filteredOptions.length)).replace("{total}", String(props.options.length))}
{translate("settings.showing_models", { count: props.filteredOptions.length, total: props.options.length })}
</div>
</Show>
</div>
@@ -364,7 +364,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<Show when={recommendedOptions().length > 0}>
<section class="space-y-2">
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
Recommended
{translate("model_picker.recommended")}
</div>
<For each={recommendedOptions()}>{({ opt, index }) => renderOption(opt, index)}</For>
</section>
@@ -373,7 +373,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<Show when={otherEnabledOptions().length > 0}>
<section class="space-y-2">
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
Other connected models
{translate("model_picker.other_connected_models")}
</div>
<For each={otherEnabledOptions()}>{({ opt, index }) => renderOption(opt, index)}</For>
</section>
@@ -382,7 +382,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<Show when={otherOptions().length > 0}>
<section class="space-y-2">
<div class="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-9">
More providers
{translate("model_picker.more_providers")}
</div>
<For each={otherOptions()}>
{(provider) => renderProviderLink(provider, provider.index)}
@@ -392,7 +392,7 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<Show when={renderedItems().length === 0}>
<div class="rounded-2xl border border-gray-6/70 bg-gray-1/40 px-4 py-6 text-sm text-gray-10">
No models match your search.
{translate("model_picker.no_results")}
</div>
</Show>
</div>

View File

@@ -14,7 +14,7 @@ import {
Plus,
} from "lucide-solid";
import { DEFAULT_SESSION_TITLE, getDisplaySessionTitle } from "../../lib/session-title";
import { getDisplaySessionTitle } from "../../lib/session-title";
import type { WorkspaceInfo } from "../../lib/tauri";
import type {
WorkspaceConnectionState,
@@ -348,7 +348,7 @@ export default function WorkspaceSessionList(props: Props) {
const depth = () => row.depth;
const isSelected = () => props.selectedSessionId === session().id;
const displayTitle = () =>
getDisplaySessionTitle(session().title, DEFAULT_SESSION_TITLE);
getDisplaySessionTitle(session().title);
const hasChildren = () =>
(tree.descendantCountBySessionId.get(session().id) ?? 0) > 0;
const hiddenChildCount = () =>

View File

@@ -1,4 +1,5 @@
import type { ModelRef, SuggestedPlugin } from "./types";
import { t } from "../i18n";
export const MODEL_PREF_KEY = "openwork.defaultModel";
export const SESSION_MODEL_PREF_KEY = "openwork.sessionModels";
@@ -16,7 +17,7 @@ export const SUGGESTED_PLUGINS: SuggestedPlugin[] = [
{
name: "opencode-scheduler",
packageName: "opencode-scheduler",
description: "Run scheduled jobs with the OpenCode scheduler plugin.",
get description() { return t("plugins.scheduler_desc"); },
tags: ["automation", "jobs"],
installMode: "simple",
},
@@ -37,44 +38,44 @@ export const CHROME_DEVTOOLS_MCP_COMMAND = ["npx", "-y", "chrome-devtools-mcp@la
export const MCP_QUICK_CONNECT: McpDirectoryInfo[] = [
{
name: "Notion",
description: "Pages, databases, and project docs in sync.",
get name() { return t("mcp.quick_connect_notion_title"); },
get description() { return t("mcp.quick_connect_notion_desc"); },
url: "https://mcp.notion.com/mcp",
type: "remote",
oauth: true,
},
{
name: "Linear",
description: "Plan sprints and ship tickets faster.",
get name() { return t("mcp.quick_connect_linear_title"); },
get description() { return t("mcp.quick_connect_linear_desc"); },
url: "https://mcp.linear.app/mcp",
type: "remote",
oauth: true,
},
{
name: "Sentry",
description: "Track releases and resolve production errors.",
get name() { return t("mcp.quick_connect_sentry_title"); },
get description() { return t("mcp.quick_connect_sentry_desc"); },
url: "https://mcp.sentry.dev/mcp",
type: "remote",
oauth: true,
},
{
name: "Stripe",
description: "Inspect payments, invoices, and subscriptions.",
get name() { return t("mcp.quick_connect_stripe_title"); },
get description() { return t("mcp.quick_connect_stripe_desc"); },
url: "https://mcp.stripe.com",
type: "remote",
oauth: true,
},
{
name: "Context7",
description: "Search product docs with richer context.",
get name() { return t("mcp.quick_connect_context7_title"); },
get description() { return t("mcp.quick_connect_context7_desc"); },
url: "https://mcp.context7.com/mcp",
type: "remote",
oauth: false,
},
{
id: CHROME_DEVTOOLS_MCP_ID,
name: "Control Chrome",
description: "Drive Chrome tabs with browser automation.",
get name() { return t("mcp.quick_connect_chrome_title"); },
get description() { return t("mcp.quick_connect_chrome_desc"); },
type: "local",
command: [...CHROME_DEVTOOLS_MCP_COMMAND],
oauth: false,

View File

@@ -1,6 +1,6 @@
import type { ProviderListItem } from "../types";
import type { ModelBehaviorOption } from "../types";
import { t, currentLocale } from "../../i18n";
import { t } from "../../i18n";
type ProviderModel = ProviderListItem["models"][string];
@@ -14,11 +14,13 @@ const WELL_KNOWN_VARIANT_ORDER = [
"max",
] as const;
const DEFAULT_BEHAVIOR_OPTION: ModelBehaviorOption = {
value: null,
label: "Provider default",
description: "Use the model's built-in default reasoning behavior.",
};
function defaultBehaviorOption(): ModelBehaviorOption {
return {
value: null,
label: t("settings.provider_default_label"),
description: t("settings.provider_default_desc"),
};
}
const humanize = (value: string) => {
const cleaned = value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
@@ -70,51 +72,51 @@ const sortVariantKeys = (keys: string[]) =>
const getBehaviorTitle = (providerID: string, model: ProviderModel, variantKeys: string[]) => {
if (variantKeys.length > 0) {
if (providerID === "anthropic") return "Extended thinking";
if (providerID === "google") return "Reasoning budget";
if (providerID === "anthropic") return t("model_behavior.title_extended_thinking");
if (providerID === "google") return t("model_behavior.title_reasoning_budget");
if (
providerID === "openai" ||
providerID === "opencode" ||
variantKeys.some((key) => ["none", "minimal", "low", "medium", "high", "xhigh"].includes(key))
) {
return "Reasoning effort";
return t("model_behavior.title_reasoning_effort");
}
return t("app.model_behavior_title", currentLocale());
return t("app.model_behavior_title");
}
if (model.reasoning) return "Built-in reasoning";
return "Standard generation";
if (model.reasoning) return t("model_behavior.title_builtin_reasoning");
return t("model_behavior.title_standard_generation");
};
const getVariantLabel = (providerID: string, key: string) => {
if (key === "none") return "Fast";
if (key === "minimal") return "Quick";
if (key === "low") return "Light";
if (key === "medium") return "Balanced";
if (key === "high") return providerID === "anthropic" ? "Extended" : "Deep";
if (key === "xhigh" || key === "max") return "Maximum";
if (key === "none") return t("model_behavior.label_fast");
if (key === "minimal") return t("model_behavior.label_quick");
if (key === "low") return t("model_behavior.label_light");
if (key === "medium") return t("model_behavior.label_balanced");
if (key === "high") return providerID === "anthropic" ? t("model_behavior.label_extended") : t("model_behavior.label_deep");
if (key === "xhigh" || key === "max") return t("model_behavior.label_maximum");
return humanize(key);
};
export const formatGenericBehaviorLabel = (value: string | null) => {
const normalized = normalizeModelBehaviorValue(value);
if (!normalized) return DEFAULT_BEHAVIOR_OPTION.label;
if (!normalized) return defaultBehaviorOption().label;
return getVariantLabel("generic", normalized);
};
const getVariantDescription = (providerID: string, key: string, label: string) => {
if (key === "none") return "Favor speed with the lightest reasoning path.";
if (key === "minimal") return "Use a very small amount of reasoning.";
if (key === "none") return t("model_behavior.desc_none");
if (key === "minimal") return t("model_behavior.desc_minimal");
if (key === "low") return providerID === "google"
? "Use a lighter reasoning budget for quicker responses."
: "Use a lighter reasoning pass before answering.";
if (key === "medium") return "Balance speed and reasoning depth.";
? t("model_behavior.desc_low_google")
: t("model_behavior.desc_low");
if (key === "medium") return t("model_behavior.desc_medium");
if (key === "high") return providerID === "anthropic"
? "Use the standard extended-thinking budget."
: "Spend more time reasoning before answering.";
? t("model_behavior.desc_high_anthropic")
: t("model_behavior.desc_high");
if (key === "xhigh" || key === "max") return providerID === "anthropic"
? "Use the largest extended-thinking budget available."
: "Use the provider's deepest reasoning profile.";
return `Use the ${label.toLowerCase()} profile.`;
? t("model_behavior.desc_max_anthropic")
: t("model_behavior.desc_max");
return t("model_behavior.desc_generic", undefined, { label: label.toLowerCase() });
};
export const getModelBehaviorOptions = (
@@ -124,7 +126,7 @@ export const getModelBehaviorOptions = (
const variantKeys = sortVariantKeys(getVariantKeys(model));
if (!variantKeys.length) return [];
return [
DEFAULT_BEHAVIOR_OPTION,
defaultBehaviorOption(),
...variantKeys.map((key) => {
const label = getVariantLabel(providerID, key);
return {
@@ -161,8 +163,8 @@ export const getModelBehaviorSummary = (
if (options.length > 0) {
return {
title,
label: selected?.label ?? DEFAULT_BEHAVIOR_OPTION.label,
description: selected?.description ?? DEFAULT_BEHAVIOR_OPTION.description,
label: selected?.label ?? defaultBehaviorOption().label,
description: selected?.description ?? defaultBehaviorOption().description,
options,
};
}
@@ -170,16 +172,16 @@ export const getModelBehaviorSummary = (
if (model.reasoning) {
return {
title,
label: "Built in",
description: "This model decides its own reasoning path and does not expose profiles here.",
label: t("model_behavior.label_builtin"),
description: t("model_behavior.desc_builtin"),
options,
};
}
return {
title,
label: "Standard",
description: "This model does not expose extra reasoning controls.",
label: t("model_behavior.label_standard"),
description: t("model_behavior.desc_standard"),
options,
};
};

View File

@@ -1,3 +1,6 @@
import { t } from "../../i18n";
/** Raw English string — used for prefix matching against stored titles. */
export const DEFAULT_SESSION_TITLE = "New session";
const GENERATED_SESSION_TITLE_PREFIX = `${DEFAULT_SESSION_TITLE} - `;
@@ -11,9 +14,9 @@ export function isGeneratedSessionTitle(title: string | null | undefined) {
export function getDisplaySessionTitle(
title: string | null | undefined,
fallback = DEFAULT_SESSION_TITLE,
fallback?: string,
) {
const trimmed = title?.trim() ?? "";
if (!trimmed || isGeneratedSessionTitle(trimmed)) return fallback;
if (!trimmed || isGeneratedSessionTitle(trimmed)) return fallback ?? t("session.default_title");
return trimmed;
}

View File

@@ -7,6 +7,7 @@ import type {
WorkspaceOpenworkConfig,
} from "../types";
import { parseTemplateFrontmatter } from "../utils";
import { t } from "../../i18n";
import browserSetupTemplate from "../data/commands/browser-setup.md?raw";
@@ -15,16 +16,11 @@ const BROWSER_AUTOMATION_QUICKSTART_PROMPT = (() => {
return (parsed?.body ?? browserSetupTemplate).trim();
})();
export const DEFAULT_EMPTY_STATE_COPY = {
title: "What do you want to do?",
body: "Pick a starting point or just type below.",
};
const DEFAULT_WELCOME_BLUEPRINT_MESSAGES: WorkspaceBlueprintSessionMessage[] = [
const defaultWelcomeBlueprintMessages = (): WorkspaceBlueprintSessionMessage[] => [
{
role: "assistant",
text:
"Hi welcome to OpenWork!\n\nPeople use us to write .csv files on their computer, connect to Chrome and automate repetitive tasks, and sync contacts to Notion.\n\nBut the only limit is your imagination.\n\nWhat would you want to do?",
text: t("blueprint.welcome_message"),
},
];
@@ -32,21 +28,21 @@ export function defaultBlueprintSessionsForPreset(_preset: string): WorkspaceBlu
return [
{
id: "welcome-to-openwork",
title: "Welcome to OpenWork",
messages: DEFAULT_WELCOME_BLUEPRINT_MESSAGES,
title: t("blueprint.welcome_title"),
messages: defaultWelcomeBlueprintMessages(),
openOnFirstLoad: true,
},
{
id: "csv-playbook",
title: "CSV workflow ideas",
title: t("blueprint.csv_session_title"),
messages: [
{
role: "assistant",
text: "I can help you generate, clean, merge, and summarize CSV files. What kind of CSV work do you want to automate?",
text: t("blueprint.csv_session_assistant"),
},
{
role: "user",
text: "I want to combine exports from multiple tools into one clean CSV.",
text: t("blueprint.csv_session_user"),
},
],
openOnFirstLoad: false,
@@ -129,18 +125,16 @@ export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlue
{
id: "automation-command",
kind: "prompt",
title: "Create a reusable command",
description: "Turn a repeated workflow into a slash command for this workspace.",
prompt:
"Help me create a reusable /command for this workspace. Ask what workflow I want to automate, then draft the command.",
title: t("blueprint.starter_command_title"),
description: t("blueprint.starter_command_desc"),
prompt: t("blueprint.starter_command_prompt"),
},
{
id: "automation-blueprint",
kind: "session",
title: "Plan an automation blueprint",
description: "Design a repeatable workflow with skills, commands, and handoff steps.",
prompt:
"Help me design a reusable automation blueprint for this workspace. Ask what should be standardized, then propose the workflow.",
title: t("blueprint.starter_blueprint_title"),
description: t("blueprint.starter_blueprint_desc"),
prompt: t("blueprint.starter_blueprint_prompt"),
},
];
case "minimal":
@@ -148,9 +142,9 @@ export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlue
{
id: "minimal-explore",
kind: "prompt",
title: "Explore this workspace",
description: "Summarize the files and suggest the best first task to tackle.",
prompt: "Summarize this workspace, point out the most important files, and suggest the best first task.",
title: t("blueprint.starter_explore_title"),
description: t("blueprint.starter_explore_desc"),
prompt: t("blueprint.starter_explore_prompt"),
},
];
default:
@@ -158,23 +152,23 @@ export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlue
{
id: "csv-help",
kind: "prompt",
title: "Work on a CSV",
description: "Clean up or generate spreadsheet data.",
prompt: "Help me create or edit CSV files on this computer.",
title: t("blueprint.starter_csv_title"),
description: t("blueprint.starter_csv_desc"),
prompt: t("blueprint.starter_csv_prompt"),
},
{
id: "starter-connect-openai",
kind: "action",
title: "Connect ChatGPT",
description: "Add your OpenAi provider so ChatGPT models are ready in new sessions.",
title: t("blueprint.starter_connect_openai_title"),
description: t("blueprint.starter_connect_openai_desc"),
action: "connect-openai",
},
{
id: "browser-automation",
kind: "session",
title: "Automate Chrome",
description: "Start a browser automation conversation right away.",
prompt: "Help me connect to Chrome and automate a repetitive task.",
title: t("blueprint.starter_chrome_title"),
description: t("blueprint.starter_chrome_desc"),
prompt: t("blueprint.starter_chrome_prompt"),
},
];
}
@@ -184,16 +178,19 @@ export function defaultBlueprintCopyForPreset(preset: string) {
switch (preset.trim().toLowerCase()) {
case "automation":
return {
title: "What do you want to automate?",
body: "Start from a reusable workflow or type your own task below.",
title: t("blueprint.automation_title"),
body: t("blueprint.automation_body"),
};
case "minimal":
return {
title: "Start with a task",
body: "Ask a question about this workspace or use a starter prompt.",
title: t("blueprint.minimal_title"),
body: t("blueprint.minimal_body"),
};
default:
return DEFAULT_EMPTY_STATE_COPY;
return {
title: t("blueprint.empty_title"),
body: t("blueprint.empty_body"),
};
}
}

View File

@@ -75,7 +75,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
<div class="inline-flex items-center gap-2 rounded-full bg-green-3 px-3 py-1">
<div class="w-2 h-2 rounded-full bg-green-9" />
<span class="text-xs font-medium text-green-11">
{connectedAppsCount()} {connectedAppsCount() === 1 ? t("extensions.app_count_one") : t("extensions.app_count_many")}
{t(connectedAppsCount() === 1 ? "extensions.app_count_one" : "extensions.app_count_many", undefined, { count: connectedAppsCount() })}
</span>
</div>
</Show>
@@ -83,7 +83,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
<div class="inline-flex items-center gap-2 rounded-full bg-gray-3 px-3 py-1">
<Cpu size={14} class="text-gray-11" />
<span class="text-xs font-medium text-gray-11">
{pluginCount()} {pluginCount() === 1 ? t("extensions.plugin_count_one") : t("extensions.plugin_count_many")}
{t(pluginCount() === 1 ? "extensions.plugin_count_one" : "extensions.plugin_count_many", undefined, { count: pluginCount() })}
</span>
</div>
</Show>

View File

@@ -92,7 +92,7 @@ import {
defaultBlueprintCopyForPreset,
defaultBlueprintStartersForPreset,
} from "../lib/workspace-blueprints";
import { DEFAULT_SESSION_TITLE, getDisplaySessionTitle } from "../lib/session-title";
import { getDisplaySessionTitle } from "../lib/session-title";
import { useSessionDisplayPreferences } from "../app-settings/session-display-preferences";
import MessageList from "../components/session/message-list";
@@ -455,7 +455,7 @@ export default function SessionView(props: SessionViewProps) {
for (const session of group.sessions) {
const sessionId = session.id?.trim() ?? "";
if (!sessionId) continue;
const title = getDisplaySessionTitle(session.title, DEFAULT_SESSION_TITLE);
const title = getDisplaySessionTitle(session.title);
const slug = session.slug?.trim() ?? "";
const updatedAt = session.time?.updated ?? session.time?.created ?? 0;
out.push({
@@ -2123,7 +2123,7 @@ export default function SessionView(props: SessionViewProps) {
if (!id) return "";
for (const group of props.workspaceSessionGroups) {
const match = group.sessions.find((session) => session.id === id);
if (match) return getDisplaySessionTitle(match.title, DEFAULT_SESSION_TITLE);
if (match) return getDisplaySessionTitle(match.title);
}
return "";
}
@@ -2209,7 +2209,7 @@ export default function SessionView(props: SessionViewProps) {
if (showPendingSessionTransition()) {
return pendingSessionTransitionTitle() || t("session.loading_session");
}
return selectedSessionTitle() || DEFAULT_SESSION_TITLE;
return selectedSessionTitle() || t("session.default_title");
});
createEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { Session } from "@opencode-ai/sdk/v2/client";
import type { ProviderListItem } from "./types";
import { t } from "../i18n";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
@@ -144,7 +145,7 @@ export function createSystemState(options: {
function openResetModal(mode: ResetOpenworkMode) {
if (anyActiveRuns()) {
options.setError("Stop active runs before resetting.");
options.setError(t("system.stop_active_runs_before_reset"));
return;
}
@@ -158,7 +159,7 @@ export function createSystemState(options: {
if (resetModalBusy()) return;
if (anyActiveRuns()) {
options.setError("Stop active runs before resetting.");
options.setError(t("system.stop_active_runs_before_reset"));
return;
}
@@ -216,60 +217,20 @@ export function createSystemState(options: {
}
const reloadCopy = createMemo(() => {
const title = t("system.reload_required");
const reasons = reloadReasons();
if (!reasons.length) {
return {
title: "Reload required",
body: "OpenWork detected changes that require reloading the OpenCode instance.",
};
}
if (reasons.length === 1 && reasons[0] === "plugins") {
return {
title: "Reload required",
body: "OpenCode loads npm plugins at startup. Reload the engine to apply opencode.json changes.",
};
}
const bodyKey =
reasons.length === 1 && reasons[0] === "plugins" ? "system.reload_body_plugins"
: reasons.length === 1 && reasons[0] === "skills" ? "system.reload_body_skills"
: reasons.length === 1 && reasons[0] === "agents" ? "system.reload_body_agents"
: reasons.length === 1 && reasons[0] === "commands" ? "system.reload_body_commands"
: reasons.length === 1 && reasons[0] === "config" ? "system.reload_body_config"
: reasons.length === 1 && reasons[0] === "mcp" ? "system.reload_body_mcp"
: reasons.length > 0 ? "system.reload_body_mixed"
: "system.reload_body_default";
if (reasons.length === 1 && reasons[0] === "skills") {
return {
title: "Reload required",
body: "OpenCode can cache skill discovery/state. Reload the engine to make newly installed skills available.",
};
}
if (reasons.length === 1 && reasons[0] === "agents") {
return {
title: "Reload required",
body: "OpenCode loads agents at startup. Reload the engine to make updated agents available.",
};
}
if (reasons.length === 1 && reasons[0] === "commands") {
return {
title: "Reload required",
body: "OpenCode loads commands at startup. Reload the engine to make updated commands available.",
};
}
if (reasons.length === 1 && reasons[0] === "config") {
return {
title: "Reload required",
body: "OpenCode reads opencode.json at startup. Reload the engine to apply configuration changes.",
};
}
if (reasons.length === 1 && reasons[0] === "mcp") {
return {
title: "Reload required",
body: "OpenCode loads MCP servers at startup. Reload the engine to activate the new connection.",
};
}
return {
title: "Reload required",
body: "OpenWork detected OpenCode configuration changes. Reload the engine to apply them.",
};
return { title, body: t(bodyKey) };
});
const canReloadEngine = createMemo(() => {
@@ -293,7 +254,7 @@ export function createSystemState(options: {
const override = options.canReloadWorkspaceEngine?.();
if (override === false) {
setReloadError("Reload is unavailable for this worker.");
setReloadError(t("system.reload_unavailable"));
return;
}
@@ -309,7 +270,7 @@ export function createSystemState(options: {
if (options.reloadWorkspaceEngine) {
const ok = await options.reloadWorkspaceEngine();
if (ok === false) {
setReloadError("Failed to reload the engine.");
setReloadError(t("system.reload_failed"));
return;
}
} else {
@@ -379,7 +340,7 @@ export function createSystemState(options: {
async function repairOpencodeCache() {
if (!isTauriRuntime()) {
setCacheRepairResult("Cache repair requires the desktop app.");
setCacheRepairResult(t("system.cache_repair_requires_desktop"));
return;
}
@@ -397,9 +358,9 @@ export function createSystemState(options: {
}
if (result.removed.length) {
setCacheRepairResult("OpenCode cache repaired. Restart the engine if it was running.");
setCacheRepairResult(t("settings.cache_repaired"));
} else {
setCacheRepairResult("No OpenCode cache found. Nothing to repair.");
setCacheRepairResult(t("settings.cache_nothing_to_repair"));
}
} catch (e) {
setCacheRepairResult(e instanceof Error ? e.message : safeStringify(e));
@@ -410,7 +371,7 @@ export function createSystemState(options: {
async function cleanupOpenworkDockerContainers() {
if (!isTauriRuntime()) {
setDockerCleanupResult("Docker cleanup requires the desktop app.");
setDockerCleanupResult(t("system.docker_cleanup_requires_desktop"));
return;
}
@@ -463,7 +424,7 @@ export function createSystemState(options: {
updateStatus().state === "idle"
? (updateStatus() as { state: "idle"; lastCheckedAt: number | null }).lastCheckedAt
: null,
message: env.reason ?? "Updates are not supported in this environment.",
message: env.reason ?? t("system.updates_not_supported"),
});
}
return;
@@ -578,7 +539,7 @@ export function createSystemState(options: {
if (!pending) return;
if (anyActiveRuns()) {
options.setError("Stop active runs before installing an update.");
options.setError(t("system.stop_runs_before_update"));
return;
}

View File

@@ -1,4 +1,5 @@
import type { Part, Session } from "@opencode-ai/sdk/v2/client";
import { t } from "../../i18n";
import type {
ArtifactItem,
MessageGroup,
@@ -256,19 +257,19 @@ export function formatRelativeTime(timestampMs: number) {
const delta = Date.now() - timestampMs;
if (delta < 0) {
return "just now";
return t("time.just_now");
}
if (delta < 60_000) {
return `${Math.max(1, Math.round(delta / 1000))}s ago`;
return t("time.seconds_ago", undefined, { count: Math.max(1, Math.round(delta / 1000)) });
}
if (delta < 60 * 60_000) {
return `${Math.max(1, Math.round(delta / 60_000))}m ago`;
return t("time.minutes_ago", undefined, { count: Math.max(1, Math.round(delta / 60_000)) });
}
if (delta < 24 * 60 * 60_000) {
return `${Math.max(1, Math.round(delta / (60 * 60_000)))}h ago`;
return t("time.hours_ago", undefined, { count: Math.max(1, Math.round(delta / (60 * 60_000))) });
}
return new Date(timestampMs).toLocaleDateString();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

491
scripts/i18n-audit.mjs Normal file
View File

@@ -0,0 +1,491 @@
#!/usr/bin/env node
/**
* i18n-audit.mjs — Find missing translations and improperly used translation keys.
*
* Usage:
* node scripts/i18n-audit.mjs # full audit (default, excludes --hardcoded, --aliases, --prune, --sort)
* node scripts/i18n-audit.mjs --missing # missing keys (in EN but not in locale)
* node scripts/i18n-audit.mjs --orphan # orphan keys (in locale but not in EN)
* node scripts/i18n-audit.mjs --duplicates # duplicate keys in any locale
* node scripts/i18n-audit.mjs --unused # unused keys (in EN but not referenced in repo)
* node scripts/i18n-audit.mjs --dangling # t() calls referencing keys not in en.ts
* node scripts/i18n-audit.mjs --aliases # aliased t() calls (translate/tr instead of t)
* node scripts/i18n-audit.mjs --placeholders # placeholder integrity check
* node scripts/i18n-audit.mjs --hardcoded # hardcoded English strings in source files
* node scripts/i18n-audit.mjs --prune # (destructive) remove unused keys from all locales
* node scripts/i18n-audit.mjs --sort # (destructive) alphabetically sort keys in all locales
*/
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { join, basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..");
const LOCALES_DIR = join(REPO_ROOT, "apps/app/src/i18n/locales");
const APP_SRC = join(REPO_ROOT, "apps/app/src");
const LOCALES = ["ja", "zh", "vi", "pt-BR", "th"];
const EN_FILE = join(LOCALES_DIR, "en.ts");
const mode = process.argv[2] ?? "--all";
const EXCLUDED_FROM_ALL = new Set(["--hardcoded", "--aliases"]);
const shouldRun = (...modes) => (mode === "--all" && !modes.some((m) => EXCLUDED_FROM_ALL.has(m))) || modes.includes(mode);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Parse a locale .ts file into a JS object via eval. */
function parseLocale(filePath) {
const content = readFileSync(filePath, "utf-8");
const match = content.match(/export default \{([\s\S]*?)\} as const;/);
if (!match) throw new Error(`Could not parse ${filePath}`);
return new Function(`return {${match[1]}}`)();
}
/** Extract translation keys from a locale .ts file (as a Set). */
function extractKeys(filePath) {
return new Set(Object.keys(parseLocale(filePath)));
}
/** Extract key→value map from a locale .ts file. */
function extractKeyValues(filePath) {
return new Map(Object.entries(parseLocale(filePath)));
}
/** Find all {placeholders} in a string. */
function findPlaceholders(str) {
return [...str.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[0]).sort();
}
/** Recursively collect all .ts/.tsx files under a directory. */
function collectSourceFiles(dir, exclude) {
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (exclude && exclude(full)) continue;
results.push(...collectSourceFiles(full, exclude));
} else if (/\.(ts|tsx)$/.test(entry.name)) {
results.push(full);
}
}
return results;
}
/** Group an array of strings by prefix (before first dot). */
function groupByPrefix(keys) {
const groups = new Map();
for (const key of keys) {
const prefix = key.split(".")[0];
groups.set(prefix, (groups.get(prefix) ?? 0) + 1);
}
return [...groups.entries()].sort((a, b) => b[1] - a[1]);
}
/** Find duplicate keys in a file (must use regex — JSON.parse dedupes silently). */
function findDuplicates(filePath) {
const content = readFileSync(filePath, "utf-8");
const seen = new Map();
const dupes = [];
for (const match of content.matchAll(/^\s*"([^"]+)"\s*:/gm)) {
const key = match[1];
if (seen.has(key)) dupes.push(key);
else seen.set(key, true);
}
return dupes;
}
// ---------------------------------------------------------------------------
// Audit
// ---------------------------------------------------------------------------
const enKeys = extractKeys(EN_FILE);
const enKeyValues = extractKeyValues(EN_FILE);
let exitCode = 0;
console.log("╔══════════════════════════════════════════════════╗");
console.log("║ i18n Audit Report ║");
console.log("╚══════════════════════════════════════════════════╝");
console.log();
// --- 1. Key counts ---
console.log("=== Key counts ===");
console.log(` en ${enKeys.size} keys (source of truth)`);
for (const locale of LOCALES) {
const file = join(LOCALES_DIR, `${locale}.ts`);
if (!existsSync(file)) {
console.log(` ${locale.padEnd(8)} MISSING FILE`);
continue;
}
const keys = extractKeys(file);
const pct = Math.round((keys.size / enKeys.size) * 100);
console.log(` ${locale.padEnd(8)} ${keys.size} keys (${pct}%)`);
}
console.log();
// --- 2. Missing keys ---
if (shouldRun("--missing")) {
console.log("=== Missing keys (in en.ts but not in locale) ===");
for (const locale of LOCALES) {
const file = join(LOCALES_DIR, `${locale}.ts`);
if (!existsSync(file)) continue;
const localeKeys = extractKeys(file);
const missing = [...enKeys].filter((k) => !localeKeys.has(k));
if (missing.length === 0) {
console.log(` ${locale}: ✓ no missing`);
} else {
console.log(` ${locale}: ✗ ${missing.length} missing`);
exitCode = 1;
if (mode !== "--summary") {
for (const [prefix, count] of groupByPrefix(missing).slice(0, 15)) {
console.log(` ${String(count).padStart(4)} ${prefix}.*`);
}
const totalGroups = new Set(missing.map((k) => k.split(".")[0])).size;
if (totalGroups > 15) console.log(` ... and ${totalGroups - 15} more groups`);
}
}
}
console.log();
}
// --- 3. Orphan keys ---
if (shouldRun("--orphan")) {
console.log("=== Orphan keys (in locale but not in en.ts) ===");
for (const locale of LOCALES) {
const file = join(LOCALES_DIR, `${locale}.ts`);
if (!existsSync(file)) continue;
const localeKeys = extractKeys(file);
const orphans = [...localeKeys].filter((k) => !enKeys.has(k));
if (orphans.length === 0) {
console.log(` ${locale}: ✓ no orphans`);
} else {
console.log(` ${locale}: ⚠ ${orphans.length} orphan keys`);
if (mode !== "--summary") {
for (const key of orphans.slice(0, 10)) console.log(` ${key}`);
if (orphans.length > 10) console.log(` ... and ${orphans.length - 10} more`);
}
}
}
console.log();
}
// --- 4. Duplicate keys ---
if (shouldRun("--duplicates")) {
console.log("=== Duplicate keys ===");
for (const locale of ["en", ...LOCALES]) {
const file = join(LOCALES_DIR, `${locale}.ts`);
if (!existsSync(file)) continue;
const dupes = findDuplicates(file);
if (dupes.length === 0) {
console.log(` ${locale}: ✓ no duplicates`);
} else {
console.log(` ${locale}: ✗ ${dupes.length} duplicate keys`);
exitCode = 1;
if (mode !== "--summary") {
for (const key of dupes.slice(0, 5)) console.log(` ${key}`);
}
}
}
console.log();
}
// --- 5. Unused keys ---
if (shouldRun("--unused", "--prune")) {
console.log("=== Unused keys (in en.ts but never referenced in repo) ===");
// Search the ENTIRE repo (not just apps/app/src) for key references
const repoSourceFiles = collectSourceFiles(REPO_ROOT, (dir) =>
["node_modules", ".git", "target", "dist", ".next", "locales"].some((x) => dir.includes(x)),
);
const allSource = repoSourceFiles.map((f) => readFileSync(f, "utf-8")).join("\n");
const unused = [...enKeys].filter((key) => !allSource.includes(key));
if (unused.length === 0) {
console.log(" ✓ all keys referenced in source");
} else {
console.log(`${unused.length} potentially unused keys`);
if (mode !== "--summary") {
for (const [prefix, count] of groupByPrefix(unused).slice(0, 15)) {
console.log(` ${String(count).padStart(4)} ${prefix}.*`);
}
if (mode === "--unused") {
console.log();
for (const key of unused) console.log(` ${key}`);
}
}
}
// --- Prune mode ---
if (mode === "--prune" && unused.length > 0) {
console.log();
console.log(` Pruning ${unused.length} unused keys from all locale files...`);
const unusedSet = new Set(unused);
const allLocaleFiles = ["en", ...LOCALES].map((l) => join(LOCALES_DIR, `${l}.ts`));
for (const file of allLocaleFiles) {
if (!existsSync(file)) continue;
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
const filtered = [];
let skipNextLine = false;
for (let i = 0; i < lines.length; i++) {
if (skipNextLine) {
skipNextLine = false;
continue;
}
const keyMatch = lines[i].match(/^\s*"([^"]+)"\s*:/);
if (keyMatch && unusedSet.has(keyMatch[1])) {
// Check if value is on the next line (multi-line entry)
if (!lines[i].includes('",') && !lines[i].includes('": "') && i + 1 < lines.length) {
skipNextLine = true;
}
continue; // skip this line
}
filtered.push(lines[i]);
}
writeFileSync(file, filtered.join("\n"));
const locale = basename(file, ".ts");
const removed = lines.length - filtered.length;
console.log(` ${locale}: removed ${removed} lines`);
}
}
console.log();
}
// --- 6. Dangling t() calls (referencing keys not in en.ts) ---
if (shouldRun("--dangling")) {
console.log("=== Dangling t() calls (keys not in en.ts) ===");
const sourceFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales"));
// Match t("key.name"), t("key.name", ...), translate("key.name"), tr("key.name")
const keyRefPattern = /\b(?:t|translate|tr)\(\s*"([a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*?)"/g;
const dangling = [];
for (const file of sourceFiles) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
for (const match of lines[i].matchAll(keyRefPattern)) {
const key = match[1];
if (!enKeys.has(key)) {
dangling.push({ key, file: file.replace(REPO_ROOT + "/", ""), line: i + 1 });
}
}
}
}
if (dangling.length === 0) {
console.log(" ✓ all t() keys exist in en.ts");
} else {
console.log(`${dangling.length} dangling references`);
exitCode = 1;
if (mode !== "--summary") {
for (const { key, file, line } of dangling) {
console.log(` ${file}:${line} → "${key}"`);
}
}
}
console.log();
// --- 7. Dynamic t() calls (keys built at runtime) ---
console.log("=== Dynamic t() calls (keys built at runtime) ===");
const dynamicPattern = /\b(?:t|translate|tr)\(\s*(`[^`]*\$\{|[^"'][^,)]*\+)/g;
const dynamicHits = [];
for (const file of sourceFiles) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (dynamicPattern.test(lines[i])) {
dynamicHits.push({ file: file.replace(REPO_ROOT + "/", ""), line: i + 1, text: lines[i].trim() });
}
dynamicPattern.lastIndex = 0;
}
}
if (dynamicHits.length === 0) {
console.log(" ✓ no dynamic key construction");
} else {
console.log(`${dynamicHits.length} dynamic key constructions (should be static strings)`);
exitCode = 1;
for (const { file, line, text } of dynamicHits) {
console.log(` ${file}:${line}`);
console.log(` ${text.slice(0, 120)}`);
}
}
console.log();
}
// --- 8. Aliased t() calls (should use t() directly, not translate/tr wrappers) ---
if (shouldRun("--aliases")) {
console.log("=== Aliased t() calls (should use t() directly) ===");
const aliasSourceFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales"));
const aliasPattern = /\b(?:translate|tr)\s*\(/g;
const aliasDefPattern = /(?:const|function)\s+(?:translate|tr)\s*[=(]/;
const hits = [];
for (const file of aliasSourceFiles) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Skip alias definitions themselves
if (aliasDefPattern.test(lines[i])) continue;
if (aliasPattern.test(lines[i])) {
hits.push({ file: file.replace(REPO_ROOT + "/", ""), line: i + 1, text: lines[i].trim() });
}
aliasPattern.lastIndex = 0;
}
}
if (hits.length === 0) {
console.log(" ✓ all calls use t() directly");
} else {
console.log(`${hits.length} aliased calls (translate/tr instead of t)`);
for (const { file, line, text } of hits) {
console.log(` ${file}:${line}`);
console.log(` ${text.slice(0, 120)}`);
}
}
console.log();
}
// --- 9. Placeholder integrity ---
if (shouldRun("--placeholders")) {
console.log("=== Placeholder integrity ===");
let problems = 0;
for (const [key, enValue] of enKeyValues) {
const enPh = findPlaceholders(enValue);
if (enPh.length === 0) continue;
for (const locale of LOCALES) {
const file = join(LOCALES_DIR, `${locale}.ts`);
if (!existsSync(file)) continue;
const localeKV = extractKeyValues(file);
const localeValue = localeKV.get(key);
if (!localeValue) continue;
const localePh = findPlaceholders(localeValue);
for (const ph of enPh) {
if (!localePh.includes(ph)) {
console.log(`${locale}/${key}: missing placeholder ${ph}`);
problems++;
exitCode = 1;
}
}
}
}
if (problems === 0) console.log(" ✓ all placeholders preserved");
else console.log(`${problems} placeholder issues`);
console.log();
}
// --- 10. Hardcoded English scan ---
if (shouldRun("--hardcoded")) {
console.log("=== Hardcoded English scan ===");
const hardcodedFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales"));
const excludePatterns = [
/import\b/, /from\s+"/, /class=/, /\btype\s/, /\bconst\s/, /variant=/,
/\bt\(/, /translate\(/, /"connected"/, /"allow"/, /"local"/, /"remote"/,
/"object"/, /"string"/, /"user"/, /"assistant"/, /"Escape"/, /"Arrow/,
/"Enter"/, /"prompt"/, /"session"/, /"automation"/, /"minimal"/, /"starter"/,
/"docker"/, /"opencode"/, /"simple"/, /"Started"/, /"Progress"/,
/^\s*\/\//, /^\s*\/\*/,
];
const englishPattern = />[A-Z][a-z]{2,}[^<]*<|"[A-Z][a-z]{3,}[a-z ]+[.!?]?"/;
for (const full of hardcodedFiles) {
const name = full.replace(APP_SRC + "/", "");
const lines = readFileSync(full, "utf-8").split("\n");
const hits = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!englishPattern.test(line)) continue;
if (excludePatterns.some((p) => p.test(line))) continue;
hits.push(` ${i + 1}: ${line.trim()}`);
if (hits.length >= 5) break;
}
if (hits.length === 0) {
console.log(` ${name}: ✓ clean`);
} else {
console.log(` ${name}: ⚠ possible hardcoded strings:`);
for (const hit of hits) console.log(hit);
}
}
console.log();
}
// --- 11. Sort ---
if (mode === "--sort") {
console.log("=== Sorting all locale files alphabetically ===");
const allLocaleFiles = ["en", ...LOCALES].map((l) => join(LOCALES_DIR, `${l}.ts`));
const PLURAL_ORDER = { _zero: 0, _one: 1, _two: 2, _few: 3, _many: 4, _other: 5 };
function sortKey(key) {
let normalized = key.replace(/\./g, "\x00");
for (const [suffix, order] of Object.entries(PLURAL_ORDER)) {
if (normalized.endsWith(suffix)) {
normalized = normalized.slice(0, -suffix.length) + `\x01${order}`;
break;
}
}
return normalized;
}
for (const file of allLocaleFiles) {
if (!existsSync(file)) continue;
const content = readFileSync(file, "utf-8");
// Extract preamble (header comment) and body
const exportMatch = content.match(/^([\s\S]*?)(export default \{)([\s\S]*?)(\} as const;\s*)$/);
if (!exportMatch) {
console.log(` ${basename(file, ".ts")}: ⚠ could not parse, skipped`);
continue;
}
const [, preamble, , body] = exportMatch;
// Eval the body as a JS object to get all key-value pairs
let obj;
try {
obj = new Function(`return {${body}}`)();
} catch (e) {
console.log(` ${basename(file, ".ts")}: ⚠ eval failed, skipped (${e.message})`);
continue;
}
// Sort keys
const sortedKeys = Object.keys(obj).sort((a, b) => {
const ak = sortKey(a);
const bk = sortKey(b);
return ak < bk ? -1 : ak > bk ? 1 : 0;
});
// Rebuild — JSON.stringify handles all escaping (\n, quotes, etc.)
const lines = sortedKeys.map((key) =>
` ${JSON.stringify(key)}: ${JSON.stringify(obj[key])},`
);
writeFileSync(file, `${preamble}export default {\n${lines.join("\n")}\n} as const;\n`);
const locale = basename(file, ".ts");
console.log(` ${locale}: ${sortedKeys.length} keys sorted`);
}
console.log();
}
// --- Done ---
console.log("=== Done ===");
console.log("Run with --missing, --orphan, --duplicates, --unused, --dangling, --placeholders, --hardcoded, --prune, or --sort for a single check.");
process.exit(exitCode);