diff --git a/.eslintrc.js b/.eslintrc.js index 921006b0766a68..d574a5e9814605 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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: { diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index a5d42e207cac2f..50f12c69ac9f16 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -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. | ✓ | diff --git a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md new file mode 100644 index 00000000000000..71d2b2d52ccb28 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md @@ -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 ( + + ); +} +``` + +```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 ( + + ); +} +``` + +```js +// ✅ Translation in edit function +function edit() { + return ( + + ); +} +``` + +```js +// ✅ Use attributes and let PHP translate +function save( { attributes } ) { + return ( + + ); +} +``` + +## 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/) diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js new file mode 100644 index 00000000000000..33dd38b9d88bb7 --- /dev/null +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -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
{ __( 'Hello World' ) }
; +} + `, + }, + { + 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: ` +// 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 { __( 'Hello World' ) }; +} + `, + 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 ( + + ); +} + `, + errors: [ + { + messageId: 'noI18nInSave', + type: 'CallExpression', + }, + ], + }, + { + code: ` +// Multiple translation calls in save +function save() { + const label = __( 'Label' ); + return
{ label }
; +} + `, + 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', + }, + ], + }, + ], +} ); diff --git a/packages/eslint-plugin/rules/no-i18n-in-save.js b/packages/eslint-plugin/rules/no-i18n-in-save.js new file mode 100644 index 00000000000000..282e730ea941b7 --- /dev/null +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -0,0 +1,121 @@ +/** + * Internal dependencies + */ +const { + TRANSLATION_FUNCTIONS, + getTranslateFunctionName, +} = require( '../utils' ); + +module.exports = { + meta: { + type: 'problem', + schema: [], + messages: { + noI18nInSave: + 'Translation functions should not be used in block save functions. Translated content is saved to the database and will not update if the language changes.', + }, + docs: { + description: 'Disallow translation functions in block save methods', + category: 'Best Practices', + recommended: false, + }, + }, + create( context ) { + let saveFunctionDepth = 0; + const filename = context.getFilename(); + + // Skip deprecated files as they preserve old behavior including translation functions + const normalizedFilename = filename.replace( /\\/g, '/' ); + const isDeprecatedFile = + normalizedFilename.includes( '/deprecated.js' ) || + normalizedFilename.includes( '/deprecated.ts' ) || + normalizedFilename.includes( '/deprecated.jsx' ) || + normalizedFilename.includes( '/deprecated.tsx' ); + + if ( isDeprecatedFile ) { + return {}; + } + + const isSaveFile = + normalizedFilename.endsWith( '/save.js' ) || + normalizedFilename.endsWith( '/save.ts' ) || + normalizedFilename.endsWith( '/save.jsx' ) || + normalizedFilename.endsWith( '/save.tsx' ); + + return { + // Track when we enter a function named 'save' + FunctionDeclaration( node ) { + if ( node.id && node.id.name === 'save' ) { + saveFunctionDepth++; + } + }, + 'FunctionDeclaration:exit'( node ) { + if ( node.id && node.id.name === 'save' ) { + saveFunctionDepth--; + } + }, + + // Track arrow functions assigned to 'save' + VariableDeclarator( node ) { + if ( + node.id && + node.id.name === 'save' && + node.init && + ( node.init.type === 'ArrowFunctionExpression' || + node.init.type === 'FunctionExpression' ) + ) { + saveFunctionDepth++; + } + }, + 'VariableDeclarator:exit'( node ) { + if ( + node.id && + node.id.name === 'save' && + node.init && + ( node.init.type === 'ArrowFunctionExpression' || + node.init.type === 'FunctionExpression' ) + ) { + saveFunctionDepth--; + } + }, + + // Track object properties named 'save' + 'Property[key.name="save"]'( node ) { + if ( + node.value && + ( node.value.type === 'FunctionExpression' || + node.value.type === 'ArrowFunctionExpression' ) + ) { + saveFunctionDepth++; + } + }, + 'Property[key.name="save"]:exit'( node ) { + if ( + node.value && + ( node.value.type === 'FunctionExpression' || + node.value.type === 'ArrowFunctionExpression' ) + ) { + saveFunctionDepth--; + } + }, + + // Check for translation function calls + CallExpression( node ) { + const { callee } = node; + const functionName = getTranslateFunctionName( callee ); + + if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) { + return; + } + + // Report if we're in a save file or inside a save function + if ( isSaveFile || saveFunctionDepth > 0 ) { + context.report( { + node, + messageId: 'noI18nInSave', + } ); + } + }, + }; + }, +};