From d29fecf1d199bd532e572c0094fd59a0caa330e0 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 24 Jul 2026 12:37:04 +0100 Subject: [PATCH] add: STU-1771 no-module-level-translations ESLint rule to enforce lazy translations Co-Authored-By: Claude Fable 5 --- AGENTS.md | 1 + apps/cli/ai/slash-commands.ts | 4 +- apps/studio/src/additional-phrases.ts | 3 + .../src/components/content-tab-overview.tsx | 2 +- apps/studio/src/components/site-menu.tsx | 2 +- .../composer/slash-autocomplete.test.ts | 4 +- .../composer/slash-autocomplete.ts | 4 +- apps/studio/src/hooks/use-import-export.tsx | 6 +- apps/studio/src/ipc-handlers.ts | 8 +- .../lib/skills-constants.ts | 2 +- .../modules/agent-instructions/lib/skills.ts | 8 +- .../components/editor-picker.tsx | 4 +- apps/studio/src/stores/installed-apps-api.ts | 4 +- .../ui/src/components/settings-view/index.tsx | 39 +++--- .../settings-view/studio-code-panel.tsx | 4 +- apps/ui/src/components/site-list/index.tsx | 4 +- .../session-view/composer/index.test.tsx | 4 +- .../session-view/composer/index.tsx | 4 +- eslint.config.mjs | 10 ++ packages/common/ai/slash-commands.ts | 2 +- packages/common/lib/import-progress.ts | 6 +- packages/common/lib/user-settings/editor.ts | 18 +-- packages/common/lib/user-settings/terminal.ts | 12 +- tools/eslint-plugin-studio/src/index.js | 2 + .../src/rules/no-module-level-translations.js | 63 +++++++++ .../no-module-level-translations.test.ts | 132 ++++++++++++++++++ 26 files changed, 284 insertions(+), 68 deletions(-) create mode 100644 tools/eslint-plugin-studio/src/rules/no-module-level-translations.js create mode 100644 tools/eslint-plugin-studio/tests/no-module-level-translations.test.ts diff --git a/AGENTS.md b/AGENTS.md index bcbd7fcab3..74214bd692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ WordPress Studio - Electron desktop app for managing local WordPress sites. Buil **IPC Handlers** (`apps/studio/src/ipc-handlers.ts`): **MUST** `export async function handlerName(event, ...args): Promise` | Handler names in `apps/studio/src/constants.ts` | All handlers MUST be async and return Promises **Storage**: **CRITICAL** - Always use file locking when writing config. Each config file has its own lockfile and helpers: `lockAppdata()` / `unlockAppdata()` for `app.json` (`apps/studio/src/storage/user-data.ts`), `lockCliConfig()` / `unlockCliConfig()` for `cli.json` (`apps/cli/lib/cli-config/core.ts`), and `lockSharedConfig()` / `unlockSharedConfig()` for `shared.json` (`packages/common/lib/shared-config.ts`). **i18n**: `@wordpress/i18n` (`__()` function), `packages/common/translations/`, `` (renderer), `loadTranslations()` (CLI) +**i18n - Never translate at module level**: **CRITICAL** - Do NOT call translation functions (`__()`, `_x()`, `_n()`, `_nx()`) in module-level constants, object literals, or arrays. They run when the module is first imported — before the locale data loads — so the string is captured once and never updates. This matters most for strings used by **React components in the desktop app**: the renderer is long-lived and the user can switch language at runtime, so a string evaluated at import time stays stale in the old language until restart. Always wrap them in a function so they are re-evaluated at render/call time: use `const getLabel = () => __( 'Label' )` instead of `const LABEL = __( 'Label' )`. The CLI is exempt — it is a one-shot process that loads the locale before importing modules — so `apps/cli` is excluded from the rule. Enforced by the `studio/no-module-level-translations` ESLint rule (`tools/eslint-plugin-studio`). ## WordPress Studio Paths diff --git a/apps/cli/ai/slash-commands.ts b/apps/cli/ai/slash-commands.ts index 5fa7d7d624..92b3b663ac 100644 --- a/apps/cli/ai/slash-commands.ts +++ b/apps/cli/ai/slash-commands.ts @@ -1,6 +1,6 @@ import { getAiModelFamily } from '@studio/common/ai/models'; import { getAiModelLabel, type AiModelId } from '@studio/common/ai/models'; -import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands'; +import { getAiSkillCommands } from '@studio/common/ai/slash-commands'; import { readAuthToken } from '@studio/common/lib/shared-config'; import { __, sprintf } from '@wordpress/i18n'; import { getAvailableAiProviders, isAiProviderReady } from 'cli/ai/auth'; @@ -615,5 +615,5 @@ export const AI_CHAT_SLASH_COMMANDS: SlashCommandDef[] = [ description: __( 'Exit the chat' ), handler: async () => 'break', }, - ...AI_SKILL_COMMANDS, + ...getAiSkillCommands(), ]; diff --git a/apps/studio/src/additional-phrases.ts b/apps/studio/src/additional-phrases.ts index 6c0dfbec53..f272558b49 100644 --- a/apps/studio/src/additional-phrases.ts +++ b/apps/studio/src/additional-phrases.ts @@ -6,6 +6,9 @@ import { __ } from '@wordpress/i18n'; +// These module-level calls exist only so the translation extractor picks up the +// strings; their return values are intentionally discarded, so they can never go +// stale and the studio/no-module-level-translations rule allows them. // Navigation strings used in external components like Guide __( 'Next' ); __( 'Previous' ); diff --git a/apps/studio/src/components/content-tab-overview.tsx b/apps/studio/src/components/content-tab-overview.tsx index 7984a7bf2d..b152943bf9 100644 --- a/apps/studio/src/components/content-tab-overview.tsx +++ b/apps/studio/src/components/content-tab-overview.tsx @@ -152,7 +152,7 @@ function ShortcutsSection( { selectedSite }: Pick< ContentTabOverviewProps, 'sel const editorConfig = editor ? supportedEditorConfig[ editor ] : false; if ( editor && editorConfig ) { buttonsArray.push( { - label: editorConfig.label, + label: editorConfig.label(), className: 'text-nowrap', icon: code, onClick: async () => { diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 626d9698cf..11cb078273 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -191,7 +191,7 @@ function SiteItem( { const isAnySiteAdding = sites.some( ( s ) => s.isAddingSite ); const finderLabel = getFileManagerLabel(); const editorLabel = - editor && supportedEditorConfig[ editor ] ? supportedEditorConfig[ editor ].label : null; + editor && supportedEditorConfig[ editor ] ? supportedEditorConfig[ editor ].label() : null; const terminalLabel = getTerminalName( terminal ); ipcApi.showSiteContextMenu( { diff --git a/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.test.ts b/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.test.ts index 2d5398d657..db11f3e29a 100644 --- a/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.test.ts +++ b/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.test.ts @@ -1,4 +1,4 @@ -import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands'; +import { getAiSkillCommands } from '@studio/common/ai/slash-commands'; import { describe, expect, it } from 'vitest'; import { getSlashCommandMatches } from './slash-autocomplete'; @@ -12,7 +12,7 @@ describe( 'getSlashCommandMatches', () => { it( 'opens with every command for a lone slash', () => { const result = getSlashCommandMatches( '/', null ); expect( result.open ).toBe( true ); - expect( result.matches ).toEqual( AI_SKILL_COMMANDS ); + expect( result.matches ).toEqual( getAiSkillCommands() ); } ); it( 'filters by case-insensitive substring (including the start)', () => { diff --git a/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.ts b/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.ts index 32cef85a9b..d3e9fb8610 100644 --- a/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.ts +++ b/apps/studio/src/components/studio-code-session/composer/slash-autocomplete.ts @@ -1,4 +1,4 @@ -import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands'; +import { getAiSkillCommands } from '@studio/common/ai/slash-commands'; import type { SkillSlashCommand } from '@studio/common/ai/slash-commands'; export interface SlashCommandMatches { @@ -30,7 +30,7 @@ export function getSlashCommandMatches( return { open: false, matches: [] }; } const query = match[ 1 ].toLowerCase(); - const matches = AI_SKILL_COMMANDS.filter( + const matches = getAiSkillCommands().filter( ( command ) => command.name.toLowerCase().includes( query ) || command.description.toLowerCase().includes( query ) diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index cab0edfd89..0160bd5523 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -66,12 +66,12 @@ const ImportExportContext = createContext< ImportExportContext >( { clearExportState: () => undefined, } ); -const WP_CONTENT_TYPE_LABELS: Record< string, string > = { +const getWpContentTypeLabels = (): Record< string, string > => ( { plugins: __( 'Importing plugins…' ), themes: __( 'Importing themes…' ), uploads: __( 'Importing media uploads…' ), other: __( 'Importing other files…' ), -}; +} ); export const ImportExportProvider = ( { children }: { children: React.ReactNode } ) => { const [ importState, setImportState ] = useState< ImportProgressState >( {} ); @@ -265,7 +265,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode data.totalItems > 0 ) { const percentage = Math.round( ( data.processedItems / data.totalItems ) * 100 ); - const baseLabel = WP_CONTENT_TYPE_LABELS[ data.type ] || __( 'Importing files…' ); + const baseLabel = getWpContentTypeLabels()[ data.type ] || __( 'Importing files…' ); statusMessage = sprintf( __( '%1$s (%2$d%%)' ), baseLabel, percentage ); } diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 59aa476a8e..24bccbe269 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -39,7 +39,7 @@ import { deleteAiSession as deleteAiSessionFromStore, loadAiSession as loadAiSessionFromStore, } from '@studio/common/ai/sessions/store'; -import { AI_SKILL_COMMANDS, buildSkillInvocationPrompt } from '@studio/common/ai/slash-commands'; +import { getAiSkillCommands, buildSkillInvocationPrompt } from '@studio/common/ai/slash-commands'; import { installSkillToSite, removeSkillFromSite, @@ -148,7 +148,7 @@ import { type InstructionFileStatus, } from 'src/modules/agent-instructions/lib/instructions'; import { - BUNDLED_SKILLS, + getBundledSkills, getSkillsStatus, installAllSkills, installSkillById, @@ -384,7 +384,7 @@ function expandSkillCommandPrompt( prompt: string ): string { return prompt; } const name = trimmed.slice( 1 ); - const match = AI_SKILL_COMMANDS.find( ( cmd ) => cmd.name === name ); + const match = getAiSkillCommands().find( ( cmd ) => cmd.name === name ); if ( ! match ) { return prompt; } @@ -634,7 +634,7 @@ export async function getWordPressSkillsStatusAllSites( ): Promise< SkillStatus[] > { const sharedConfig = await readSharedConfig(); const selectedSkills = sharedConfig.selectedSkills ?? []; - return BUNDLED_SKILLS.map( ( skill ) => ( { + return getBundledSkills().map( ( skill ) => ( { ...skill, installed: selectedSkills.includes( skill.id ), } ) ); diff --git a/apps/studio/src/modules/agent-instructions/lib/skills-constants.ts b/apps/studio/src/modules/agent-instructions/lib/skills-constants.ts index b826ed4c65..ffd809f48c 100644 --- a/apps/studio/src/modules/agent-instructions/lib/skills-constants.ts +++ b/apps/studio/src/modules/agent-instructions/lib/skills-constants.ts @@ -10,7 +10,7 @@ export interface SkillStatus extends SkillConfig { installed: boolean; } -export const BUNDLED_SKILLS: SkillConfig[] = [ +export const getBundledSkills = (): SkillConfig[] => [ { id: 'studio-cli', displayName: __( 'Studio CLI' ), diff --git a/apps/studio/src/modules/agent-instructions/lib/skills.ts b/apps/studio/src/modules/agent-instructions/lib/skills.ts index cb6d87fb58..f29706de20 100644 --- a/apps/studio/src/modules/agent-instructions/lib/skills.ts +++ b/apps/studio/src/modules/agent-instructions/lib/skills.ts @@ -2,14 +2,14 @@ import nodePath from 'path'; import { installSkillToSite, removeSkillFromSite } from '@studio/common/lib/agent-skills'; import { pathExists } from '@studio/common/lib/fs-utils'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; -import { BUNDLED_SKILLS, type SkillStatus } from './skills-constants'; +import { getBundledSkills, type SkillStatus } from './skills-constants'; import type { SiteRuntime } from '@studio/common/lib/site-runtime'; -export { BUNDLED_SKILLS, type SkillConfig, type SkillStatus } from './skills-constants'; +export { getBundledSkills, type SkillConfig, type SkillStatus } from './skills-constants'; export async function getSkillsStatus( sitePath: string ): Promise< SkillStatus[] > { return Promise.all( - BUNDLED_SKILLS.map( async ( skill ) => { + getBundledSkills().map( async ( skill ) => { const skillMdPath = nodePath.join( sitePath, '.agents', 'skills', skill.id, 'SKILL.md' ); const installed = await pathExists( skillMdPath ); return { ...skill, installed }; @@ -22,7 +22,7 @@ export async function installAllSkills( overwrite: boolean = false ): Promise< void > { const bundledPath = getAiInstructionsPath(); - const tasks = BUNDLED_SKILLS.map( ( skill ) => + const tasks = getBundledSkills().map( ( skill ) => installSkillToSite( site, bundledPath, skill.id, overwrite ) ); const results = await Promise.allSettled( tasks ); diff --git a/apps/studio/src/modules/user-settings/components/editor-picker.tsx b/apps/studio/src/modules/user-settings/components/editor-picker.tsx index 14d5384b5d..2fcaca2cd9 100644 --- a/apps/studio/src/modules/user-settings/components/editor-picker.tsx +++ b/apps/studio/src/modules/user-settings/components/editor-picker.tsx @@ -37,7 +37,7 @@ export const EditorPicker = ( { value, onChange, disabled }: EditorPickerProps ) { installedEditors.map( ( [ editorKey, editorConfig ] ) => ( ) ) } @@ -45,7 +45,7 @@ export const EditorPicker = ( { value, onChange, disabled }: EditorPickerProps ) { uninstalledEditors.map( ( [ editorKey, editorConfig ] ) => ( ) ) } diff --git a/apps/studio/src/stores/installed-apps-api.ts b/apps/studio/src/stores/installed-apps-api.ts index 8b52a53f5b..757834d49f 100644 --- a/apps/studio/src/stores/installed-apps-api.ts +++ b/apps/studio/src/stores/installed-apps-api.ts @@ -192,7 +192,7 @@ export const selectInstalledTerminals = createSelector( .filter( ( terminal ) => installedApps && installedApps[ terminal ] ) .map( ( terminal ) => - [ terminal, terminalConfig[ terminal ].name ] as [ SupportedTerminal, string ] + [ terminal, terminalConfig[ terminal ].name() ] as [ SupportedTerminal, string ] ); } ); @@ -205,7 +205,7 @@ export const selectUninstalledTerminals = createSelector( .filter( ( terminal ) => ! installedApps || ! installedApps[ terminal ] ) .map( ( terminal ) => - [ terminal, terminalConfig[ terminal ].name ] as [ SupportedTerminal, string ] + [ terminal, terminalConfig[ terminal ].name() ] as [ SupportedTerminal, string ] ); } ); diff --git a/apps/ui/src/components/settings-view/index.tsx b/apps/ui/src/components/settings-view/index.tsx index c3737d3947..16084a11d1 100644 --- a/apps/ui/src/components/settings-view/index.tsx +++ b/apps/ui/src/components/settings-view/index.tsx @@ -52,7 +52,7 @@ function editorElements( installedApps: InstalledApps | undefined ) { return SUPPORTED_EDITORS.filter( ( editor ) => ! installedApps || installedApps[ editor ] ).map( ( editor ) => ( { value: editor, - label: supportedEditorConfig[ editor ].label, + label: supportedEditorConfig[ editor ].label(), } ) ); } @@ -62,29 +62,33 @@ function terminalElements( installedApps: InstalledApps | undefined ) { ( terminal ) => ! installedApps || installedApps[ terminal ] ).map( ( terminal ) => ( { value: terminal, - label: terminalConfig[ terminal ].name, + label: terminalConfig[ terminal ].name(), } ) ); } -const COLOR_SCHEME_ELEMENTS: { value: ColorScheme; label: string }[] = [ - { value: 'system', label: __( 'System' ) }, - { value: 'light', label: __( 'Light' ) }, - { value: 'dark', label: __( 'Dark' ) }, -]; +function colorSchemeElements(): { value: ColorScheme; label: string }[] { + return [ + { value: 'system', label: __( 'System' ) }, + { value: 'light', label: __( 'Light' ) }, + { value: 'dark', label: __( 'Dark' ) }, + ]; +} function isColorScheme( value: unknown ): value is ColorScheme { return value === 'system' || value === 'light' || value === 'dark'; } -const QUIT_SITES_BEHAVIOR_ELEMENTS: { +function quitSitesBehaviorElements(): { value: QuitSitesBehavior | typeof UNSET; label: string; -}[] = [ - { value: UNSET, label: __( 'Ask every time' ) }, - { value: 'leave-running', label: __( 'Keep sites running' ) }, - { value: 'stop-and-auto-start', label: __( 'Stop, restart on next launch' ) }, - { value: 'stop', label: __( 'Stop sites' ) }, -]; +}[] { + return [ + { value: UNSET, label: __( 'Ask every time' ) }, + { value: 'leave-running', label: __( 'Keep sites running' ) }, + { value: 'stop-and-auto-start', label: __( 'Stop, restart on next launch' ) }, + { value: 'stop', label: __( 'Stop sites' ) }, + ]; +} const LOCALE_ELEMENTS: { value: SupportedLocale; label: string }[] = Object.entries( supportedLocaleNames @@ -155,9 +159,10 @@ function AppearancePicker( { value: ColorScheme; onChange: ( value: ColorScheme ) => void; } ) { + const elements = colorSchemeElements(); const activeIndex = Math.max( 0, - COLOR_SCHEME_ELEMENTS.findIndex( ( option ) => option.value === value ) + elements.findIndex( ( option ) => option.value === value ) ); return ( @@ -168,7 +173,7 @@ function AppearancePicker( { aria-label={ __( 'Appearance' ) } data-active-index={ activeIndex } > - { COLOR_SCHEME_ELEMENTS.map( ( option ) => ( + { elements.map( ( option ) => (