diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md
index 1fb043a4ec0..060db7211d9 100644
--- a/packages/grafana-eslint-rules/README.md
+++ b/packages/grafana-eslint-rules/README.md
@@ -111,3 +111,53 @@ const getStyles = (theme: GrafanaTheme2) => ({
### `theme-token-usage`
Used to find all instances of `theme` tokens being used in the codebase and emit the counts as metrics. Should **not** be used as an actual lint rule!
+
+### `no-untranslated-strings`
+
+Check if strings are marked for translation.
+
+```tsx
+// Bad ❌
+
+ Copied
+
+
+// Good ✅
+
+ Copied
+
+
+```
+
+#### Passing variables to translations
+
+```tsx
+// Bad ❌
+const SearchTitle = ({ term }) => (
+
+ Results for {term}
+
+);
+
+//Good ✅
+const SearchTitle = ({ term }) => (
+
+ Results for {{ term }}
+
+);
+```
+
+#### How to translate props or attributes
+
+Right now, we only check if a string is wrapped up by the `Trans` tag. We currently do not apply this rule to props, attributes or similar, but we also ask for them to be translated with the `t()` function.
+
+```tsx
+// Bad ❌
+;
+
+// Good ✅
+const placeholder = t('form.username-placeholder', 'Username');
+return ;
+```
+
+Check more info about how translations work in Grafana in [Internationalization.md](https://github.com/grafana/grafana/blob/main/contribute/internationalization.md)
diff --git a/packages/grafana-eslint-rules/index.cjs b/packages/grafana-eslint-rules/index.cjs
index d7df5009b06..3ebefd0cae5 100644
--- a/packages/grafana-eslint-rules/index.cjs
+++ b/packages/grafana-eslint-rules/index.cjs
@@ -1,6 +1,7 @@
const noAriaLabelSelectors = require('./rules/no-aria-label-e2e-selectors.cjs');
const noBorderRadiusLiteral = require('./rules/no-border-radius-literal.cjs');
const noUnreducedMotion = require('./rules/no-unreduced-motion.cjs');
+const noUntranslatedStrings = require('./rules/no-utranslated-strings.cjs');
const themeTokenUsage = require('./rules/theme-token-usage.cjs');
module.exports = {
@@ -9,5 +10,6 @@ module.exports = {
'no-aria-label-selectors': noAriaLabelSelectors,
'no-border-radius-literal': noBorderRadiusLiteral,
'theme-token-usage': themeTokenUsage,
+ 'no-untranslated-strings': noUntranslatedStrings,
},
};
diff --git a/packages/grafana-eslint-rules/rules/no-utranslated-strings.cjs b/packages/grafana-eslint-rules/rules/no-utranslated-strings.cjs
new file mode 100644
index 00000000000..b1e90de21f4
--- /dev/null
+++ b/packages/grafana-eslint-rules/rules/no-utranslated-strings.cjs
@@ -0,0 +1,46 @@
+// @ts-check
+const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
+
+const createRule = ESLintUtils.RuleCreator(
+ (name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
+);
+
+const noUntranslatedStrings = createRule({
+ create(context) {
+ return {
+ JSXText(node) {
+ const ancestors = context.getAncestors();
+ const isEmpty = !node.value.trim();
+ const hasTransAncestor = ancestors.some((ancestor) => {
+ return (
+ ancestor.type === AST_NODE_TYPES.JSXElement &&
+ ancestor.openingElement.type === AST_NODE_TYPES.JSXOpeningElement &&
+ ancestor.openingElement.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ ancestor.openingElement.name.name === 'Trans'
+ );
+ });
+ if (!isEmpty && !hasTransAncestor) {
+ context.report({
+ node,
+ messageId: 'noUntranslatedStrings',
+ });
+ }
+ },
+ };
+ },
+ name: 'no-untranslated-strings',
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'Check untranslated strings',
+ },
+ messages: {
+ noUntranslatedStrings: 'No untranslated strings. Wrap text with ',
+ },
+ schema: [],
+ },
+ defaultOptions: [],
+});
+
+
+module.exports = noUntranslatedStrings;