Skip to content

Commit 8303849

Browse files
ryanwelcherzaerl
andauthored
Translate the mail dialog, the Playground web server and the copy-path error (#647)
## Why #630 is the last batch of #622. This PR is the part of it that doesn't wait on the five open batches (#636, #643, #644, #645, #646): the mail dialog, the Playground web server and the copy-path error were still plain English, and `applyDoneMessage` spliced a verb and a noun into an English sentence for any pair it had no sentence for. ## What changes - The mail dialog: "From:", "To:", "CC:", "Date:", the Rendered and Raw tabs, and the "Email" title of a mail with no subject. - The Playground web server: its Start and Stop buttons, the loading announcement, "Starting…", the panel's heading, "Stopped", and "Server exited with code %d". - The copy-path failure: the alert and the clipboard-unavailable message in it. - `applyDoneMessage`'s fallback is `_x('Done', 'an action that finished')` instead of `` `${verb} the ${noun}` ``. No caller reaches it today: the three in `use-apply-patch.jsx` pass only the five pairs it has sentences for. Not in this PR, because another open PR already wraps them or because #630 says to wait for one: | Item | Where | |---|---| | `COPY_BUTTON_LABELS` | #646 | | "Dismiss" on the deep-link notice, "Choose application…" | #644 | | "Setting up new site…" | #636 | | `use-dev-server.jsx` leftovers | after #646 | | `apply-card.cjs:191` period | after #645 | | `project-type.cjs` `workItem.label` | after #643, #636, #644 | | The full `--lang=en-XA` walk | after all five merge | ## How to test this Two en-XA journeys cover this, and both are red without the change: ```bash # from the repository root npm run build:once npx playwright test --project=journeys tests/e2e/journeys/i18n.spec.js -g "a mail the site sent|Playground web server" ``` - **a mail the site sent is fully translatable**: a mail on its Rendered tab and its Raw tab, and a mail with no subject, whose dialog is titled "Email". - **the Playground web server is fully translatable**: stopped, starting (including the live-region announcement), running, and after it exits with code 1. The English journeys in `mail.spec.js` still pass, so an English speaker sees the same text as before. What the journeys can't reach is the copy-path alert. It needs the clipboard to refuse a write, and I couldn't stage that. To look by hand (any platform, current head): run `npx electron . --lang=en-XA` from the repository root, start a site's dev server, and open a mail from the Email tray. Every label and both tabs show accented and in brackets. The Playground web server only appears in a build that ships `local-playground-web`, so a checkout won't show it. **What must not have happened:** with no `--lang`, the mail dialog and the web server read exactly as they did before. ## Risks and limitations - Found while writing the web server journey: `@wordpress/a11y` adds a screen-reader-only "Notifications" paragraph at DOM-ready, before the app loads its locale, so it stays English. #630 doesn't list it, so it isn't fixed here. - The web server's other errors come from main (`'local-playground-web directory not found.'`, or a thrown error) and stay English. That belongs with the main-process strings, not this renderer batch. ## Related Part of #630. Part of #622. --- <details> <summary>Review outcome</summary> **4 [fix here] · 2 [follow-up]. All 4 fixed in c9c5c0c.** One more finding was dropped as wrong (below). - **Review:** completed by a separate agent with a fresh context, against `.github/instructions/code-review.instructions.md`. Reviewed head 4f8b808, base e94f507 (trunk). `npm run lint` clean, `npm test` 2033/2033. - **Fixed:** - The web server journey could pass with the loading announcement unwrapped, because `speak()` writes outside the area scanned. It now asserts `#a11y-speak-polite`. - `__('Email')` only shows for a mail with no subject. The journey now opens one. - The `Done` fallback wasn't in the unit test that runs every sentence through a catalog. It is now. - A bare `__('Done')` would share a msgid with the `Done` button in `@wordpress/components`. It now has a context. Each new journey assertion was checked to fail with its string unwrapped. - **Deferred:** - The mail journey excuses any text that is part of the mail. No label is excused today, but matching whole text nodes would be stricter. - The web server's main-process error strings (see Risks). - **Dropped:** the finding that "Dismiss", "Choose application…", `COPY_BUTTON_LABELS` and "Setting up new site…" were unwrapped. The reviewer read the issues, not the PR diffs; #644, #646 and #636 wrap them. - **Since review:** 4f8b808 → c9c5c0c, which contains only the fixes above. Lint, unit tests and the i18n and mail journeys were rerun on c9c5c0c. </details> Nothing on screen changes in English, so there are no screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Francesco Bigiarini <francesco.bigiarini@gmail.com>
1 parent 60f1eac commit 8303849

5 files changed

Lines changed: 126 additions & 20 deletions

File tree

‎src/renderer/components/email-modal.jsx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Modal, TabPanel } from '@wordpress/components';
2+
import { __ } from '@wordpress/i18n';
23

34
function formatEmailDate(email) {
45
if (email.sentAt) return new Date(email.sentAt).toLocaleString();
@@ -14,19 +15,28 @@ function formatEmailDate(email) {
1415
export function EmailModal({ email, onClose }) {
1516
return (
1617
<Modal
17-
title={email.subject || 'Email'}
18+
title={email.subject || __('Email')}
1819
onRequestClose={onClose}
1920
shouldCloseOnClickOutside
2021
isFullScreen
2122
>
2223
<div className="email-view">
2324
<div className="email-view-headers">
24-
<div><strong>From:</strong> {email.from || ''}</div>
25-
<div><strong>To:</strong> {email.to || ''}</div>
26-
{email.cc ? (<div><strong>CC:</strong> {email.cc}</div>) : null}
27-
<div><strong>Date:</strong> {formatEmailDate(email)}</div>
25+
<div><strong>{__('From:')}</strong> {email.from || ''}</div>
26+
<div><strong>{__('To:')}</strong> {email.to || ''}</div>
27+
{email.cc ? (<div><strong>{__('CC:')}</strong> {email.cc}</div>) : null}
28+
<div><strong>{__('Date:')}</strong> {formatEmailDate(email)}</div>
2829
</div>
29-
<TabPanel className="email-tabs" activeClass="is-active" tabs={[{name:'rendered',title:'Rendered'},{name:'raw',title:'Raw'}]}>
30+
<TabPanel
31+
className="email-tabs"
32+
activeClass="is-active"
33+
tabs={[
34+
// translators: The tab that shows an email as a reader would see it.
35+
{ name: 'rendered', title: __('Rendered') },
36+
// translators: The tab that shows an email as it was sent, headers and all.
37+
{ name: 'raw', title: __('Raw') }
38+
]}
39+
>
3040
{(tab)=> tab.name==='rendered' ? (
3141
<div className="email-view-rendered">
3242
{email.html ? (

‎src/renderer/confirmations.cjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* that half-happened (#381).
2525
*/
2626

27-
const { __, sprintf } = require('@wordpress/i18n');
27+
const { __, _x, sprintf } = require('@wordpress/i18n');
2828

2929
// At most this many confirmations are kept on screen at once. A burst — a
3030
// double-click, a chain of steps finishing together — collapses to the most
@@ -204,7 +204,9 @@ function applyDoneMessage(verb, noun) {
204204
'Restored previous branch': __('Restored the previous branch'),
205205
'Restored saved work': __('Restored the saved work')
206206
};
207-
return sentences[`${verb} ${noun}`] || `${verb} the ${noun}`;
207+
// A pair with no sentence of its own is still confirmed, without English
208+
// words spliced into it.
209+
return sentences[`${verb} ${noun}`] || _x('Done', 'an action that finished');
208210
}
209211

210212
/**

‎src/renderer/index.jsx‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,8 @@ function App({ settingsState }) {
439439
const res = await window.api.startPlaygroundWeb(
440440
({ data }) => setWebLogs((v) => v + String(data)),
441441
(url) => { const u = (url || 'http://127.0.0.1:39372/').replace(/\/$/,'/'); setWebUrl(u); setWebStarting(false); },
442-
(payload) => { setWebUrl(''); if (payload && typeof payload.code === 'number' && payload.code !== 0) setWebError(`Server exited with code ${payload.code}`); }
442+
// translators: %d: the code the server exited with, a number.
443+
(payload) => { setWebUrl(''); if (payload && typeof payload.code === 'number' && payload.code !== 0) setWebError(sprintf(__('Server exited with code %d'), payload.code)); }
443444
);
444445
if (res && res.ok && res.url) {
445446
const u = String(res.url).replace(/\/$/,'/');
@@ -554,14 +555,14 @@ function App({ settingsState }) {
554555
<Stack direction="row" align="center" justify="flex-end" gap="sm" className="window-notice">
555556
<UiButton
556557
loading={webStarting}
557-
loadingAnnouncement="Starting the Playground web server"
558+
loadingAnnouncement={__('Starting the Playground web server')}
558559
variant={webUrl ? 'outline' : 'solid'}
559560
tone={webUrl ? 'neutral' : 'brand'}
560561
onClick={togglePlaygroundWeb}
561-
>{webUrl ? 'Stop Playground web server' : 'Start Playground web server'}</UiButton>
562+
>{webUrl ? __('Stop Playground web server') : __('Start Playground web server')}</UiButton>
562563
{webStarting || webUrl ? (
563564
<Text variant="body-sm">
564-
{webStarting ? 'Starting…' : (
565+
{webStarting ? __('Starting…') : (
565566
<a href={webUrl || 'http://127.0.0.1:39372/'} onClick={(e) => { e.preventDefault(); window.api.openExternal(webUrl || 'http://127.0.0.1:39372/'); }}>{webUrl || 'http://127.0.0.1:39372/'}</a>
566567
)}
567568
</Text>
@@ -574,13 +575,13 @@ function App({ settingsState }) {
574575
<UiCard.Root className="window-notice">
575576
<UiCard.Content render={<Stack direction="column" gap="sm" />}>
576577
<Stack direction="row" align="center" justify="space-between" gap="sm">
577-
<Text variant="heading-md">Playground web server</Text>
578+
<Text variant="heading-md">{__('Playground web server')}</Text>
578579
<Text variant="body-sm" className="muted-label">
579-
{webStarting ? 'Starting…' : null}
580+
{webStarting ? __('Starting…') : null}
580581
{!webStarting && webUrl ? (
581582
<a href={webUrl} onClick={(e)=>{ e.preventDefault(); window.api.openExternal(webUrl); }}>{webUrl}</a>
582583
) : null}
583-
{!webStarting && !webUrl ? 'Stopped' : null}
584+
{!webStarting && !webUrl ? __('Stopped') : null}
584585
</Text>
585586
</Stack>
586587
{webError ? <Text variant="body-sm" className="error-text">{webError}</Text> : null}
@@ -867,13 +868,17 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null,
867868
const writePathToClipboard = useCallback(async () => {
868869
try {
869870
if (!navigator?.clipboard?.writeText) {
870-
throw new Error('Clipboard access is not available in this environment');
871+
throw new Error(__('Clipboard access is not available in this environment'));
871872
}
872873
await navigator.clipboard.writeText(sitePath);
873874
return true;
874875
} catch (err) {
875876
// eslint-disable-next-line no-alert -- see the note above confirmAnd.
876-
alert('Unable to copy path: ' + (err?.message ?? String(err)));
877+
alert(sprintf(
878+
// translators: %s: why the path could not be copied, a sentence.
879+
__('Unable to copy path: %s'),
880+
err?.message ?? String(err)
881+
));
877882
return false;
878883
}
879884
}, [sitePath]);

‎tests/e2e/journeys/i18n.spec.js‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,3 +1402,90 @@ test( 'what the window says when a folder will not open is fully translatable, f
14021402
await menu.getByRole( 'menuitem', { name: /^\[Šĥóŵ íñ / } ).click();
14031403
await scanned( filled( 'The file manager would not open the folder: %s', 'stand-in reason' ) );
14041404
} );
1405+
1406+
test( 'a mail the site sent is fully translatable, on its Rendered and its Raw tab', async ( { session } ) => {
1407+
// One mail in the store, loaded when the server starts, which is answered
1408+
// by stand-ins as in mail.spec.js. What the mail says is the site's, and
1409+
// is left out of the scan.
1410+
const MAIL = {
1411+
id: 'e2e-reset',
1412+
subject: '[Test Site] Password Reset',
1413+
from: 'WordPress <wordpress@example.test>',
1414+
to: 'admin@example.test',
1415+
cc: 'auditor@example.test',
1416+
date: '2026-08-10T09:30:00.000Z',
1417+
sentAt: '2026-08-10T09:30:00.000Z',
1418+
text: 'A password reset was requested.',
1419+
html: '<p>Someone has requested a <strong>password reset</strong>.</p>',
1420+
headers: {},
1421+
raw: 'Subject: [Test Site] Password Reset\nX-Mailer: PHPMailer\n\nA password reset was requested.',
1422+
};
1423+
// No subject: the dialog has to be titled by the app instead.
1424+
const UNTITLED = { ...MAIL, id: 'e2e-untitled', subject: '', cc: undefined, sentAt: '2026-08-10T09:00:00.000Z', date: '2026-08-10T09:00:00.000Z' };
1425+
const theMails = ( text ) => [ MAIL.subject, MAIL.from, MAIL.to, MAIL.cc, MAIL.raw, 'Someone has requested a password reset.' ].some( ( part ) => part.includes( text ) ) || text.includes( '2026' );
1426+
const site = await makeSite( session );
1427+
const { app, page } = await session.start( { ...site.settings, [ `siteMail:${ site.dir }` ]: [ MAIL, UNTITLED ] }, { lang: 'en-XA' } );
1428+
await app.evaluate( ( { ipcMain } ) => {
1429+
ipcMain.removeHandler( 'npm:run-script' );
1430+
ipcMain.handle( 'npm:run-script', async () => ( { runId: 'e2e-mail' } ) );
1431+
ipcMain.removeHandler( 'playground:start' );
1432+
ipcMain.handle( 'playground:start', async () => ( { ok: true } ) );
1433+
} );
1434+
await page.getByRole( 'button', { name: pseudoLocalize( 'Start development server' ), exact: true } ).click( { timeout: 30_000 } );
1435+
await page.getByRole( 'button', { name: pseudoLocalize( 'Toggle Email' ), exact: true } ).click();
1436+
await page.getByRole( 'button', { name: /\[Test Site\] Password Reset$/ } ).click( { timeout: 30_000 } );
1437+
1438+
const dialog = page.getByRole( 'dialog', { name: MAIL.subject } );
1439+
await expect( dialog.getByRole( 'tab', { name: pseudoLocalize( 'Rendered' ), exact: true } ) ).toHaveAttribute( 'aria-selected', 'true' );
1440+
await expect( dialog.getByText( pseudoLocalize( 'CC:' ), { exact: true } ) ).toBeVisible();
1441+
expect( ( await unwrapped( dialog ) ).filter( ( text ) => ! theMails( text ) ) ).toEqual( [] );
1442+
1443+
await dialog.getByRole( 'tab', { name: pseudoLocalize( 'Raw' ), exact: true } ).click();
1444+
await expect( dialog.getByText( 'X-Mailer: PHPMailer' ) ).toBeVisible();
1445+
expect( ( await unwrapped( dialog ) ).filter( ( text ) => ! theMails( text ) ) ).toEqual( [] );
1446+
1447+
await page.keyboard.press( 'Escape' );
1448+
await page.getByRole( 'button', { name: new RegExp( `${ pseudoLocalize( '(no subject)' ).replace( /[[\]()]/g, '\\$&' ) }$` ) } ).click();
1449+
const untitled = page.getByRole( 'dialog', { name: pseudoLocalize( 'Email' ), exact: true } );
1450+
await expect( untitled ).toBeVisible();
1451+
expect( ( await unwrapped( untitled ) ).filter( ( text ) => ! theMails( text ) ) ).toEqual( [] );
1452+
} );
1453+
1454+
test( 'the Playground web server is fully translatable, stopped, starting, running and after it exits', async ( { session } ) => {
1455+
// The server is offered only where a build ships it, which a checkout
1456+
// does not, and the page asks as it mounts: so the stand-ins are in
1457+
// place before the window is. Starting is never answered; the test says
1458+
// what the main process would say instead. With no site, the server is
1459+
// above the middle of the window, and that is what is scanned.
1460+
const URL = 'http://127.0.0.1:39372/';
1461+
await session.start( undefined, { lang: 'en-XA' } );
1462+
const { app, page } = await session.restart( { beforeWindow: ( launched ) => launched.evaluate( ( { ipcMain } ) => {
1463+
ipcMain.removeHandler( 'playground-web:available' );
1464+
ipcMain.handle( 'playground-web:available', () => true );
1465+
ipcMain.removeHandler( 'playground-web:start' );
1466+
ipcMain.handle( 'playground-web:start', () => new Promise( () => {} ) );
1467+
} ) } );
1468+
const tell = ( channel, payload ) => app.evaluate( ( { BrowserWindow }, [ to, what ] ) => {
1469+
BrowserWindow.getAllWindows()[ 0 ].webContents.send( to, what );
1470+
}, [ channel, payload ] );
1471+
const notices = page.locator( '.page-body-notices' );
1472+
const SERVER_OUTPUT = 'listening for requests';
1473+
1474+
await page.getByRole( 'button', { name: pseudoLocalize( 'Start Playground web server' ), exact: true } ).click( { timeout: 30_000 } );
1475+
await expect( page.getByText( pseudoLocalize( 'Playground web server' ), { exact: true } ) ).toBeVisible();
1476+
await expect( page.getByText( pseudoLocalize( 'Starting…' ), { exact: true } ).first() ).toBeVisible();
1477+
expect( await unwrapped( notices ) ).toEqual( [] );
1478+
// The button says it is starting through a live region, outside the
1479+
// notices, so that is read on its own.
1480+
await expect( page.locator( '#a11y-speak-polite' ) ).toHaveText( pseudoLocalize( 'Starting the Playground web server' ) );
1481+
1482+
await tell( 'playground-web:log', { type: 'stdout', data: `${ SERVER_OUTPUT }\n` } );
1483+
await tell( 'playground-web:url', { url: URL } );
1484+
await expect( page.getByRole( 'button', { name: pseudoLocalize( 'Stop Playground web server' ), exact: true } ) ).toBeVisible();
1485+
expect( ( await unwrapped( notices ) ).filter( ( text ) => text !== URL && text !== SERVER_OUTPUT ) ).toEqual( [] );
1486+
1487+
await tell( 'playground-web:stopped', { code: 1 } );
1488+
await expect( page.getByText( pseudoLocalize( 'Stopped' ), { exact: true } ) ).toBeVisible();
1489+
await expect( page.getByText( pseudoLocalize( 'Server exited with code %d' ).replace( '%d', '1' ), { exact: true } ) ).toBeVisible();
1490+
expect( ( await unwrapped( notices ) ).filter( ( text ) => text !== SERVER_OUTPUT ) ).toEqual( [] );
1491+
} );

‎tests/unit/confirmations.test.cjs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ test('each thing the apply flow can have done is confirmed in a sentence of its
159159
assert.strictEqual(applyDoneMessage('Restored', 'saved work'), 'Restored the saved work');
160160
});
161161

162-
test('a pair it has no sentence for is still said, as the flow words it', () => {
163-
assert.strictEqual(applyDoneMessage('Rebuilt', 'site'), 'Rebuilt the site');
162+
test('a pair it has no sentence for is still confirmed, in a word that splices nothing in', () => {
163+
assert.strictEqual(applyDoneMessage('Rebuilt', 'site'), 'Done');
164164
});
165165

166166
test('a saved patch and edits saved before an update are confirmed by the file\'s name', () => {
@@ -188,6 +188,7 @@ test('every sentence this module words goes through the translator, around what
188188
'Checked out the pull request': ['T checked out'],
189189
'Restored the previous branch': ['T restored branch'],
190190
'Restored the saved work': ['T restored work'],
191+
'an action that finished\u0004Done': ['T done'],
191192
'Patch saved to %s': ['T saved %s'],
192193
'Saved your changes to %s and reset the working tree': ['T reset %s'],
193194
'The site could not be created. %s': ['T not created: %s'],
@@ -203,6 +204,7 @@ test('every sentence this module words goes through the translator, around what
203204
applyDoneMessage('Checked out', 'pull request'),
204205
applyDoneMessage('Restored', 'previous branch'),
205206
applyDoneMessage('Restored', 'saved work'),
207+
applyDoneMessage('Rebuilt', 'site'),
206208
patchSavedMessage('a.diff'),
207209
savedAndResetMessage('a.patch'),
208210
setupFailureMessage('ENOSPC'),
@@ -212,7 +214,7 @@ test('every sentence this module words goes through the translator, around what
212214
deleteFailureMessage({ ok: false, reason: 'remove-failed', path: '/sites/demo', code: 'EBUSY' }),
213215
deleteFailureMessage({ ok: false, reason: 'remove-failed', path: '/sites/demo' })
214216
], [
215-
'T applied', 'T reverted', 'T checked out', 'T restored branch', 'T restored work',
217+
'T applied', 'T reverted', 'T checked out', 'T restored branch', 'T restored work', 'T done',
216218
'T saved a.diff', 'T reset a.patch', 'T not created: ENOSPC',
217219
'T dry run', 'T opened 9', 'T opened 9 at WordPress/gutenberg',
218220
'T kept /sites/demo (EBUSY)', 'T kept /sites/demo'

0 commit comments

Comments
 (0)