From bd3dd4fac97d7ca3563bc75c40ad39718f951c17 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 17 Feb 2026 11:17:29 +0000 Subject: [PATCH 01/13] Add no-i18n-in-save rule --- packages/eslint-plugin/configs/i18n.js | 1 + .../docs/rules/no-i18n-in-save.md | 101 ++++++++++++++++ .../eslint-plugin/rules/no-i18n-in-save.js | 108 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/no-i18n-in-save.md create mode 100644 packages/eslint-plugin/rules/no-i18n-in-save.js diff --git a/packages/eslint-plugin/configs/i18n.js b/packages/eslint-plugin/configs/i18n.js index fb8cad233232d4..0723212f4c4dc4 100644 --- a/packages/eslint-plugin/configs/i18n.js +++ b/packages/eslint-plugin/configs/i18n.js @@ -10,5 +10,6 @@ module.exports = { '@wordpress/i18n-ellipsis': 'error', '@wordpress/i18n-no-flanking-whitespace': 'error', '@wordpress/i18n-hyphenated-range': 'error', + '@wordpress/no-i18n-in-save': 'error', }, }; 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..076887267e5993 --- /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 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 + +This rule should not be disabled. If you think you need an exception, consider: + +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/no-i18n-in-save.js b/packages/eslint-plugin/rules/no-i18n-in-save.js new file mode 100644 index 00000000000000..13e75a5bf32d3d --- /dev/null +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -0,0 +1,108 @@ +/** + * 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: true, + }, + }, + create( context ) { + let insideSaveFunction = false; + const filename = context.getFilename(); + const isSaveFile = + filename.endsWith( '/save.js' ) || + filename.endsWith( '/save.ts' ) || + filename.endsWith( '/save.jsx' ) || + filename.endsWith( '/save.tsx' ); + + return { + // Track when we enter a function named 'save' + FunctionDeclaration( node ) { + if ( node.id && node.id.name === 'save' ) { + insideSaveFunction = true; + } + }, + 'FunctionDeclaration:exit'( node ) { + if ( node.id && node.id.name === 'save' ) { + insideSaveFunction = false; + } + }, + + // 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' ) + ) { + insideSaveFunction = true; + } + }, + 'VariableDeclarator:exit'( node ) { + if ( + node.id && + node.id.name === 'save' && + node.init && + ( node.init.type === 'ArrowFunctionExpression' || + node.init.type === 'FunctionExpression' ) + ) { + insideSaveFunction = false; + } + }, + + // Track object properties named 'save' + 'Property[key.name="save"]'( node ) { + if ( + node.value && + ( node.value.type === 'FunctionExpression' || + node.value.type === 'ArrowFunctionExpression' ) + ) { + insideSaveFunction = true; + } + }, + 'Property[key.name="save"]:exit'( node ) { + if ( + node.value && + ( node.value.type === 'FunctionExpression' || + node.value.type === 'ArrowFunctionExpression' ) + ) { + insideSaveFunction = false; + } + }, + + // 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 || insideSaveFunction ) { + context.report( { + node, + messageId: 'noI18nInSave', + } ); + } + }, + }; + }, +}; From 6af390f2cc3b3ed28554a491c416ab90bb7aa7dd Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 17 Feb 2026 11:17:45 +0000 Subject: [PATCH 02/13] Add tests --- .../rules/__tests__/no-i18n-in-save.js | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js 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..c9106c6ab45327 --- /dev/null +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -0,0 +1,222 @@ +/** + * 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' ) }
; +} + `, + }, + ], + invalid: [ + { + 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', + }, + ], + }, + ], +} ); From d934ee784df08b9d64fc4a9ba4b881bc80a8770e Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 17 Feb 2026 12:01:38 +0000 Subject: [PATCH 03/13] Exclude deprecated files --- .../eslint-plugin/rules/__tests__/no-i18n-in-save.js | 9 +++++++++ packages/eslint-plugin/rules/no-i18n-in-save.js | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js index c9106c6ab45327..ff7b32cc24a767 100644 --- a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -59,6 +59,15 @@ export default function Edit() { } `, }, + { + code: ` +// Translation in deprecated save function is allowed +function save() { + return __( 'Hello World' ); +} + `, + filename: '/path/to/block/deprecated.js', + }, ], invalid: [ { diff --git a/packages/eslint-plugin/rules/no-i18n-in-save.js b/packages/eslint-plugin/rules/no-i18n-in-save.js index 13e75a5bf32d3d..4f5f3c711d0a27 100644 --- a/packages/eslint-plugin/rules/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -23,6 +23,18 @@ module.exports = { create( context ) { let insideSaveFunction = false; const filename = context.getFilename(); + + // Skip deprecated files as they preserve old behavior including translation functions + const isDeprecatedFile = + filename.includes( '/deprecated.js' ) || + filename.includes( '/deprecated.ts' ) || + filename.includes( '/deprecated.jsx' ) || + filename.includes( '/deprecated.tsx' ); + + if ( isDeprecatedFile ) { + return {}; + } + const isSaveFile = filename.endsWith( '/save.js' ) || filename.endsWith( '/save.ts' ) || From 980bfa69d70762940fc0312eb3eee7e60441964c Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 17 Feb 2026 12:42:05 +0000 Subject: [PATCH 04/13] Normalize filenames before checking paths --- .../rules/__tests__/no-i18n-in-save.js | 9 +++++++++ packages/eslint-plugin/rules/no-i18n-in-save.js | 17 +++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js index ff7b32cc24a767..d33202bf163a87 100644 --- a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -68,6 +68,15 @@ function save() { `, 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: [ { diff --git a/packages/eslint-plugin/rules/no-i18n-in-save.js b/packages/eslint-plugin/rules/no-i18n-in-save.js index 4f5f3c711d0a27..040aad457bd356 100644 --- a/packages/eslint-plugin/rules/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -25,21 +25,22 @@ module.exports = { const filename = context.getFilename(); // Skip deprecated files as they preserve old behavior including translation functions + const normalizedFilename = filename.replace( /\\/g, '/' ); const isDeprecatedFile = - filename.includes( '/deprecated.js' ) || - filename.includes( '/deprecated.ts' ) || - filename.includes( '/deprecated.jsx' ) || - filename.includes( '/deprecated.tsx' ); + normalizedFilename.includes( '/deprecated.js' ) || + normalizedFilename.includes( '/deprecated.ts' ) || + normalizedFilename.includes( '/deprecated.jsx' ) || + normalizedFilename.includes( '/deprecated.tsx' ); if ( isDeprecatedFile ) { return {}; } const isSaveFile = - filename.endsWith( '/save.js' ) || - filename.endsWith( '/save.ts' ) || - filename.endsWith( '/save.jsx' ) || - filename.endsWith( '/save.tsx' ); + 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' From 0e1f51a2cd73cdeb3b1f3342fe3d58e350e94328 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 17 Feb 2026 15:50:37 +0000 Subject: [PATCH 05/13] Move new rule to local eslintrc file --- .eslintrc.js | 1 + packages/eslint-plugin/configs/i18n.js | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 921006b0766a68..0ee89dd00a2e56 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -148,6 +148,7 @@ module.exports = { allowedTextDomain: 'default', }, ], + '@wordpress/no-i18n-in-save': 'error', '@wordpress/no-unsafe-wp-apis': 'off', '@wordpress/data-no-store-string-literals': 'error', 'eslint-comments/no-unused-disable': 'error', diff --git a/packages/eslint-plugin/configs/i18n.js b/packages/eslint-plugin/configs/i18n.js index 0723212f4c4dc4..fb8cad233232d4 100644 --- a/packages/eslint-plugin/configs/i18n.js +++ b/packages/eslint-plugin/configs/i18n.js @@ -10,6 +10,5 @@ module.exports = { '@wordpress/i18n-ellipsis': 'error', '@wordpress/i18n-no-flanking-whitespace': 'error', '@wordpress/i18n-hyphenated-range': 'error', - '@wordpress/no-i18n-in-save': 'error', }, }; From 39f45711c85857c773e946e6128bcc80f0f66d57 Mon Sep 17 00:00:00 2001 From: Sarah Norris <1645628+mikachan@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:44:34 +0000 Subject: [PATCH 06/13] Update packages/eslint-plugin/docs/rules/no-i18n-in-save.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/eslint-plugin/docs/rules/no-i18n-in-save.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md index 076887267e5993..0037cea9b0b408 100644 --- a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md +++ b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md @@ -10,7 +10,7 @@ This rule aims to prevent the use of i18n translation functions (`__`, `_x`, `_n When translation functions are used in save methods: -1. Translation is saved to database: The translated text is stored at the time of saving, not when the content is displayed +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 From 9cb4d16513c3acc175204fa2a2335d5b7766996c Mon Sep 17 00:00:00 2001 From: Sarah Norris <1645628+mikachan@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:46:26 +0000 Subject: [PATCH 07/13] Update packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../rules/__tests__/no-i18n-in-save.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js index d33202bf163a87..1242b995c44e74 100644 --- a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -81,6 +81,21 @@ function save() { 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' ); } From 1cdbe80bda145060610eff639f92cf5e16aa138c Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Fri, 20 Feb 2026 15:48:49 +0000 Subject: [PATCH 08/13] Use counter in insideSaveFunction instead of boolean --- .../rules/__tests__/no-i18n-in-save.js | 15 +++++++++++++++ packages/eslint-plugin/rules/no-i18n-in-save.js | 16 ++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js index 1242b995c44e74..33dd38b9d88bb7 100644 --- a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js @@ -251,5 +251,20 @@ function save() { }, ], }, + { + 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 index 040aad457bd356..3f400a90ff7dec 100644 --- a/packages/eslint-plugin/rules/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -21,7 +21,7 @@ module.exports = { }, }, create( context ) { - let insideSaveFunction = false; + let saveFunctionDepth = 0; const filename = context.getFilename(); // Skip deprecated files as they preserve old behavior including translation functions @@ -46,12 +46,12 @@ module.exports = { // Track when we enter a function named 'save' FunctionDeclaration( node ) { if ( node.id && node.id.name === 'save' ) { - insideSaveFunction = true; + saveFunctionDepth++; } }, 'FunctionDeclaration:exit'( node ) { if ( node.id && node.id.name === 'save' ) { - insideSaveFunction = false; + saveFunctionDepth--; } }, @@ -64,7 +64,7 @@ module.exports = { ( node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression' ) ) { - insideSaveFunction = true; + saveFunctionDepth++; } }, 'VariableDeclarator:exit'( node ) { @@ -75,7 +75,7 @@ module.exports = { ( node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression' ) ) { - insideSaveFunction = false; + saveFunctionDepth--; } }, @@ -86,7 +86,7 @@ module.exports = { ( node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression' ) ) { - insideSaveFunction = true; + saveFunctionDepth++; } }, 'Property[key.name="save"]:exit'( node ) { @@ -95,7 +95,7 @@ module.exports = { ( node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression' ) ) { - insideSaveFunction = false; + saveFunctionDepth--; } }, @@ -109,7 +109,7 @@ module.exports = { } // Report if we're in a save file or inside a save function - if ( isSaveFile || insideSaveFunction ) { + if ( isSaveFile || saveFunctionDepth > 0 ) { context.report( { node, messageId: 'noI18nInSave', From 2c4749bc5a557fb12110edaeb4ad9c0949ab4539 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 24 Feb 2026 17:08:22 +0000 Subject: [PATCH 09/13] Move rule definition to block-library --- .eslintrc.js | 1 - packages/block-library/.eslintrc.js | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/block-library/.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index 0ee89dd00a2e56..921006b0766a68 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -148,7 +148,6 @@ module.exports = { allowedTextDomain: 'default', }, ], - '@wordpress/no-i18n-in-save': 'error', '@wordpress/no-unsafe-wp-apis': 'off', '@wordpress/data-no-store-string-literals': 'error', 'eslint-comments/no-unused-disable': 'error', diff --git a/packages/block-library/.eslintrc.js b/packages/block-library/.eslintrc.js new file mode 100644 index 00000000000000..471fdd2048e201 --- /dev/null +++ b/packages/block-library/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + '@wordpress/no-i18n-in-save': 'error', + }, +}; From 90caaec05e24638f8455c31083adffaba7e307ef Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 25 Feb 2026 11:02:15 +0000 Subject: [PATCH 10/13] Update readme --- packages/eslint-plugin/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index a5d42e207cac2f..09ff021dedba85 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. | ✓ | From 7bbe256bae8750eca85734e36b0427fbff2689e6 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 25 Feb 2026 11:14:45 +0000 Subject: [PATCH 11/13] Remove true recommendation --- packages/eslint-plugin/README.md | 2 +- packages/eslint-plugin/rules/no-i18n-in-save.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 09ff021dedba85..50f12c69ac9f16 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -84,7 +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-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/rules/no-i18n-in-save.js b/packages/eslint-plugin/rules/no-i18n-in-save.js index 3f400a90ff7dec..282e730ea941b7 100644 --- a/packages/eslint-plugin/rules/no-i18n-in-save.js +++ b/packages/eslint-plugin/rules/no-i18n-in-save.js @@ -17,7 +17,7 @@ module.exports = { docs: { description: 'Disallow translation functions in block save methods', category: 'Best Practices', - recommended: true, + recommended: false, }, }, create( context ) { From db738853f1b5b5114cb0b225f85914a1fbb4ea37 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 25 Feb 2026 11:15:42 +0000 Subject: [PATCH 12/13] Soften the when not to use info --- packages/eslint-plugin/docs/rules/no-i18n-in-save.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md index 0037cea9b0b408..71d2b2d52ccb28 100644 --- a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md +++ b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md @@ -89,7 +89,7 @@ function save( { attributes } ) { ## When not to use -This rule should not be disabled. If you think you need an exception, consider: +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 From 4399554c52f16b17966b6309a9eba6e7db1c6c50 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 26 Feb 2026 15:07:03 +0000 Subject: [PATCH 13/13] Move rule to root file --- .eslintrc.js | 6 ++++++ packages/block-library/.eslintrc.js | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 packages/block-library/.eslintrc.js 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/block-library/.eslintrc.js b/packages/block-library/.eslintrc.js deleted file mode 100644 index 471fdd2048e201..00000000000000 --- a/packages/block-library/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - rules: { - '@wordpress/no-i18n-in-save': 'error', - }, -};