Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,12 @@ module.exports = {
],
},
},
{
files: [ 'packages/block-library/src/*/save.[tj]s?(x)' ],
rules: {
'@wordpress/no-i18n-in-save': 'error',
},
},
{
files: [ 'packages/interactivity*/src/**' ],
rules: {
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ The granular rulesets will not define any environment globals. As such, if they
| [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ |
| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | ✓ |
| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | ✓ |
| [no-i18n-in-save](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-i18n-in-save.md) | Disallow translation functions in block save methods. | |
| [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ |
| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packages | ✓ |
| [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ |
Expand Down
101 changes: 101 additions & 0 deletions packages/eslint-plugin/docs/rules/no-i18n-in-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Disallow translation functions in block save methods (no-i18n-in-save)

Translation functions should never be used in block `save` functions or `save.js` files.

## Rule details

This rule aims to prevent the use of i18n translation functions (`__`, `_x`, `_n`, `_nx`) in block save methods.

### Why?

When translation functions are used in save methods:

1. Translation is saved to the database: The translated text is stored at the time of saving, not when the content is displayed
2. No dynamic updates: If the site language changes, previously saved content will not update
3. Block validation errors: Switching languages causes validation errors because the saved HTML no longer matches what the save function generates
4. Content locked to language: Content becomes permanently associated with the language active at save time

### What to do instead

- For static text: Use plain English text that PHP can replace during rendering
- For dynamic labels: Use block attributes and let PHP handle translation during rendering
- For user content: Store as attributes and render/translate in PHP

## Examples

### Incorrect

```js
// ❌ Translation in save function
function save() {
return (
<button>
<span>{ __( 'Click me', 'my-plugin' ) }</span>
</button>
);
}
```

```js
// ❌ Translation in arrow function save
const save = () => {
return __( 'Hello World' );
};
```

```js
// ❌ Translation in object method
const settings = {
save() {
return _x( 'Label', 'context' );
},
};
```

### Correct

```js
// ✅ No translation in save, handled by PHP
function save() {
return (
<button>
<span>Click me</span>
</button>
);
}
```

```js
// ✅ Translation in edit function
function edit() {
return (
<button>
<span>{ __( 'Click me', 'my-plugin' ) }</span>
</button>
);
}
```

```js
// ✅ Use attributes and let PHP translate
function save( { attributes } ) {
return (
<button>
<span>{ attributes.label }</span>
</button>
);
}
```

## When not to use

Consider carefully before disabling this rule. In most cases, the better fix is to refactor the block to avoid translation in save altogether. If you have a genuine edge case (e.g. a migration block that must preserve previously saved translated strings), prefer one of these alternatives instead:

1. Using a render callback in PHP to handle translation
2. Storing translatable content in block attributes
3. Using static text that PHP replaces during rendering

## Further Reading

- [Block API Reference](https://developer.wordpress.org/block-editor/reference-guides/block-api/)
- [Internationalization in WordPress](https://developer.wordpress.org/apis/internationalization/)
270 changes: 270 additions & 0 deletions packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';

/**
* Internal dependencies
*/
import rule from '../no-i18n-in-save';

const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
} );

ruleTester.run( 'no-i18n-in-save', rule, {
valid: [
{
code: `
function edit() {
return __( 'Hello World' );
}
`,
},
{
code: `
const edit = () => {
return __( 'Hello World' );
};
`,
},
{
code: `
const settings = {
edit() {
return __( 'Hello World' );
},
};
`,
},
{
code: `
// Translation functions are fine in non-save files
function render() {
return __( 'Hello World' );
}
`,
},
{
code: `
// Translation in edit function
export default function Edit() {
return <div>{ __( 'Hello World' ) }</div>;
}
`,
},
{
code: `
// Translation in deprecated save function is allowed
function save() {
return __( 'Hello World' );
}
`,
filename: '/path/to/block/deprecated.js',
},
{
code: `
// Translation in deprecated save function with Windows path
function save() {
return __( 'Hello World' );
}
`,
filename: 'D:\\path\\to\\block\\deprecated.js',
},
],
invalid: [
{
code: `
Comment thread
mikachan marked this conversation as resolved.
// Translation in save file outside save function
function render() {
return __( 'Hello World' );
}
`,
filename: '/path/to/block/save.js',
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
function save() {
return __( 'Hello World' );
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
const save = () => {
return __( 'Hello World' );
};
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
const save = function() {
return __( 'Hello World' );
};
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
export default function save() {
return <span>{ __( 'Hello World' ) }</span>;
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
const settings = {
save() {
return __( 'Hello World' );
},
};
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
const settings = {
save: () => __( 'Hello World' ),
};
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
function save() {
return _x( 'Hello', 'greeting' );
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
function save() {
const count = 5;
return _n( 'One item', 'Multiple items', count );
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
function save() {
const count = 5;
return _nx( 'One item', 'Multiple items', count, 'context' );
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
function save() {
return (
<button>
<span>{ __( 'Click me' ) }</span>
</button>
);
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
// Multiple translation calls in save
function save() {
const label = __( 'Label' );
return <div title={ _x( 'Title', 'context' ) }>{ label }</div>;
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
{
code: `
// Translation after a nested inner function named save must still be caught
function save() {
function save() {}
return __( 'Hello World' );
}
`,
errors: [
{
messageId: 'noI18nInSave',
type: 'CallExpression',
},
],
},
],
} );
Loading
Loading