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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType>` | 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/`, `<I18nProvider>` (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

Expand Down
4 changes: 2 additions & 2 deletions apps/cli/ai/slash-commands.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -615,5 +615,5 @@ export const AI_CHAT_SLASH_COMMANDS: SlashCommandDef[] = [
description: __( 'Exit the chat' ),
handler: async () => 'break',
},
...AI_SKILL_COMMANDS,
...getAiSkillCommands(),
];
3 changes: 3 additions & 0 deletions apps/studio/src/additional-phrases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
2 changes: 1 addition & 1 deletion apps/studio/src/components/content-tab-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/site-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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( {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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)', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 )
Expand Down
6 changes: 3 additions & 3 deletions apps/studio/src/hooks/use-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 >( {} );
Expand Down Expand Up @@ -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 );
}

Expand Down
8 changes: 4 additions & 4 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -148,7 +148,7 @@ import {
type InstructionFileStatus,
} from 'src/modules/agent-instructions/lib/instructions';
import {
BUNDLED_SKILLS,
getBundledSkills,
getSkillsStatus,
installAllSkills,
installSkillById,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ),
} ) );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down
8 changes: 4 additions & 4 deletions apps/studio/src/modules/agent-instructions/lib/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ export const EditorPicker = ( { value, onChange, disabled }: EditorPickerProps )
<optgroup label={ __( 'Available editors' ) }>
{ installedEditors.map( ( [ editorKey, editorConfig ] ) => (
<option key={ editorKey } value={ editorKey }>
{ editorConfig.label }
{ editorConfig.label() }
</option>
) ) }
</optgroup>
) }
<optgroup label={ __( 'Not installed' ) }>
{ uninstalledEditors.map( ( [ editorKey, editorConfig ] ) => (
<option key={ editorKey } value={ editorKey } disabled>
{ editorConfig.label }
{ editorConfig.label() }
</option>
) ) }
</optgroup>
Expand Down
4 changes: 2 additions & 2 deletions apps/studio/src/stores/installed-apps-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
);
}
);
Expand All @@ -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 ]
);
}
);
39 changes: 22 additions & 17 deletions apps/ui/src/components/settings-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
} )
);
}
Expand All @@ -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
Expand Down Expand Up @@ -159,9 +163,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 (
Expand All @@ -172,7 +177,7 @@ function AppearancePicker( {
aria-label={ __( 'Appearance' ) }
data-active-index={ activeIndex }
>
{ COLOR_SCHEME_ELEMENTS.map( ( option ) => (
{ elements.map( ( option ) => (
<button
key={ option.value }
type="button"
Expand Down Expand Up @@ -325,7 +330,7 @@ function PreferencesPanel( {
label={ __( 'When quitting with running sites' ) }
className={ styles.selectControlWide }
value={ data.quitSitesBehavior }
options={ QUIT_SITES_BEHAVIOR_ELEMENTS }
options={ quitSitesBehaviorElements() }
onChange={ ( quitSitesBehavior ) => onChange( { quitSitesBehavior } ) }
/>
</PreferenceRow>
Expand Down
4 changes: 2 additions & 2 deletions apps/ui/src/components/settings-view/studio-code-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface FormData {
content: string;
}

const FIELDS: Field< FormData >[] = [
const getFields = (): Field< FormData >[] => [
{
id: 'content',
type: 'text',
Expand Down Expand Up @@ -77,7 +77,7 @@ export function StudioCodePanel() {
<div className={ styles.preferencesPanel }>
<DataForm< FormData >
data={ { content } }
fields={ FIELDS }
fields={ getFields() }
form={ FORM }
onChange={ ( update ) =>
setEdits(
Expand Down
4 changes: 2 additions & 2 deletions apps/ui/src/components/site-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,9 @@ function SiteActionsMenu( {
};

const editor = userPreferences?.editor;
const editorLabel = editor ? supportedEditorConfig[ editor ].label : null;
const editorLabel = editor ? supportedEditorConfig[ editor ].label() : null;
const terminal = userPreferences?.terminal;
const terminalLabel = terminal ? terminalConfig[ terminal ].name : null;
const terminalLabel = terminal ? terminalConfig[ terminal ].name() : null;

const handleOpenInEditor = () => {
void connector.openSiteInEditor( site.id ).catch( ( error ) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_MODEL } from '@studio/common/ai/models';
import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands';
import { getAiSkillCommands } from '@studio/common/ai/slash-commands';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
Expand Down Expand Up @@ -145,7 +145,7 @@ describe( 'Composer menu', () => {
fireEvent.keyDown( skillsItem, { key: 'ArrowRight' } );

await waitFor( () => {
expect( screen.getByText( AI_SKILL_COMMANDS[ 0 ].description ) ).toBeInTheDocument();
expect( screen.getByText( getAiSkillCommands()[ 0 ].description ) ).toBeInTheDocument();
} );
} );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { watchComposerFilePaste } from '@studio/common/ai/composer-attachments';
import { AI_MODELS, getAiModelFamily, getAiModelLabel } from '@studio/common/ai/models';
import { isStudioCustomEntryOfType } from '@studio/common/ai/sessions/entry-types';
import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands';
import { getAiSkillCommands } from '@studio/common/ai/slash-commands';
import { useQueryClient } from '@tanstack/react-query';
import { __, sprintf } from '@wordpress/i18n';
import {
Expand Down Expand Up @@ -920,7 +920,7 @@ export const Composer = forwardRef< ComposerHandle, ComposerProps >( function Co
/>
</Menu.SubmenuTrigger>
<Menu.Popup side="right" align="start" className={ styles.skillsMenuPopup }>
{ AI_SKILL_COMMANDS.map( ( command ) => (
{ getAiSkillCommands().map( ( command ) => (
<Menu.Item
key={ command.name }
className={ styles.skillMenuItem }
Expand Down
10 changes: 10 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export default defineConfig(
},
],
'react-hooks/set-state-in-effect': 'off',
'studio/no-module-level-translations': 'error',
'studio/no-redundant-cx': 'error',
'studio/require-lock-before-save': [
'error',
Expand Down Expand Up @@ -178,5 +179,14 @@ export default defineConfig(
},
],
},
},
{
// The CLI is a one-shot process that loads the locale before importing
// modules, so module-level translations are safe there. The rule only
// matters for the long-lived renderer where the language can change at runtime.
files: [ 'apps/cli/**' ],
rules: {
'studio/no-module-level-translations': 'off',
},
}
);
2 changes: 1 addition & 1 deletion packages/common/ai/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export interface SkillSlashCommand {
description: string;
}

export const AI_SKILL_COMMANDS: SkillSlashCommand[] = [
export const getAiSkillCommands = (): SkillSlashCommand[] => [
{ name: 'annotate', description: __( 'Annotate site elements visually in a browser' ) },
{ name: 'taxonomist', description: __( 'Optimize category taxonomy with AI' ) },
{ name: 'need-for-speed', description: __( 'Run a performance audit on a site' ) },
Expand Down
Loading
Loading