diff --git a/packages/eslint-plugin-template/docs/rules/i18n.md b/packages/eslint-plugin-template/docs/rules/i18n.md
index 4c6722e70..7ec23e91e 100644
--- a/packages/eslint-plugin-template/docs/rules/i18n.md
+++ b/packages/eslint-plugin-template/docs/rules/i18n.md
@@ -30,6 +30,10 @@ The rule accepts an options object with the following properties:
```ts
interface Options {
+ /**
+ * Default: `true`
+ */
+ allowMarkupInContent?: boolean;
boundTextAllowedPattern?: string;
/**
* Default: `true`
@@ -130,6 +134,33 @@ interface Options {
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+{ value, plural, =0 {No elements} =1 {111} }
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
#### Custom Config
```json
@@ -1098,6 +1129,111 @@ interface Options {
```
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ Text to translate
+
+ ~~~~~~~~~~~
+
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+ Text to translate
+
+ ~~~~~~~~~~~
+
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+
+ ~~~~~~~~~~
+ Text to translate
+ ~~~~~~~~~~~~~~~~~
+
+ ~~~~~~
+
+```
+
@@ -1551,6 +1687,34 @@ interface Options {
#### ✅ Valid Code
+```html
+
+ { value, plural, =0 {No elements} =1 {111} }
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
```html
{ value, plural, =0 {No elements
} =1 {111} }
@@ -2124,6 +2288,134 @@ interface Options {
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+ Text to translate{{ Bound }}
+
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+ Text to translate{{ Bound }}
+
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+ { value, plural, =0 {No elements} =1 {111} }
+
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/i18n": [
+ "error",
+ {
+ "allowMarkupInContent": false,
+ "checkId": false
+ }
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+ { value, plural, =0 {
No elements
} =1 {111} }
+
+```
+
+
+
+---
+
+
+
#### Default Config
```json
diff --git a/packages/eslint-plugin-template/src/rules/i18n.ts b/packages/eslint-plugin-template/src/rules/i18n.ts
index a632f2a83..f1745425e 100644
--- a/packages/eslint-plugin-template/src/rules/i18n.ts
+++ b/packages/eslint-plugin-template/src/rules/i18n.ts
@@ -63,6 +63,7 @@ const DEFAULT_ALLOWED_ATTRIBUTES: ReadonlySet = new Set([
type Options = [
{
+ readonly allowMarkupInContent?: boolean;
readonly boundTextAllowedPattern?: string;
readonly checkAttributes?: boolean;
readonly checkDuplicateId?: boolean;
@@ -82,7 +83,8 @@ export type MessageIds =
| 'i18nDuplicateCustomId'
| 'suggestAddI18nAttribute'
| 'i18nMissingDescription'
- | 'i18nMissingMeaning';
+ | 'i18nMissingMeaning'
+ | 'i18nMarkupInContent';
type StronglyTypedI18n = Omit & {
i18n: I18nMeta;
parent?: AST;
@@ -105,6 +107,7 @@ type StronglyTypedBoundTextOrIcuOrText = (
};
export const RULE_NAME = 'i18n';
const DEFAULT_OPTIONS: Options[number] = {
+ allowMarkupInContent: true,
checkAttributes: true,
checkId: true,
checkDuplicateId: true,
@@ -138,6 +141,10 @@ export default createESLintRule({
{
type: 'object',
properties: {
+ allowMarkupInContent: {
+ type: 'boolean',
+ default: DEFAULT_OPTIONS.allowMarkupInContent,
+ },
boundTextAllowedPattern: {
type: 'string',
},
@@ -191,6 +198,8 @@ export default createESLintRule({
suggestAddI18nAttribute: 'Add the `i18n` attribute',
i18nMissingDescription: `Missing i18n description on element. See more at ${STYLE_GUIDE_LINK_METADATA_FOR_TRANSLATION}`,
i18nMissingMeaning: `Missing i18n meaning on element. See more at ${STYLE_GUIDE_LINK_METADATA_FOR_TRANSLATION}`,
+ i18nMarkupInContent:
+ 'Avoid HTML markup in an element with an i18n attribute.',
},
},
defaultOptions: [DEFAULT_OPTIONS],
@@ -198,6 +207,7 @@ export default createESLintRule({
context,
[
{
+ allowMarkupInContent,
boundTextAllowedPattern,
checkAttributes,
checkId,
@@ -211,7 +221,7 @@ export default createESLintRule({
],
) {
const parserServices = getTemplateParserServices(context);
- const sourceCode = context.getSourceCode();
+ const sourceCode = context.sourceCode;
const allowedBoundTextPattern = boundTextAllowedPattern
? new RegExp(boundTextAllowedPattern)
: DEFAULT_ALLOWED_BOUND_TEXT_PATTERN;
@@ -246,16 +256,23 @@ export default createESLintRule({
const { i18n, sourceSpan } = node;
const parent = getNextElementOrTemplateParent(node);
- if (
- isTagAllowed(allowedTags, node) ||
- isElementWithI18n(parent) ||
- isNgTemplateWithI18n(parent)
- ) {
+ if (isTagAllowed(allowedTags, node)) {
return;
}
const loc = parserServices.convertNodeSourceSpanToLoc(sourceSpan);
+ if (isElementWithI18n(parent) || isNgTemplateWithI18n(parent)) {
+ if (allowMarkupInContent) {
+ return;
+ }
+ context.report({
+ messageId: 'i18nMarkupInContent',
+ loc,
+ });
+ return;
+ }
+
const customId = getI18nCustomId(i18n);
if (checkId) {
@@ -400,10 +417,13 @@ export default createESLintRule({
}
return {
- ...((checkId || requireDescription || requireMeaning) && {
- ':matches(Element$1, Template[tagName="ng-template"])[i18n]'(
- node: StronglyTypedElement | StronglyTypedTemplate,
- ) {
+ ...((!allowMarkupInContent ||
+ checkId ||
+ requireDescription ||
+ requireMeaning) && {
+ [`:matches(Element$1, Template[tagName="ng-template"])${
+ allowMarkupInContent ? '[i18n]' : ''
+ }`](node: StronglyTypedElement | StronglyTypedTemplate) {
handleElementOrTemplate(node);
},
}),
diff --git a/packages/eslint-plugin-template/tests/rules/i18n/cases.ts b/packages/eslint-plugin-template/tests/rules/i18n/cases.ts
index 81c7f9302..e82e2de4a 100644
--- a/packages/eslint-plugin-template/tests/rules/i18n/cases.ts
+++ b/packages/eslint-plugin-template/tests/rules/i18n/cases.ts
@@ -9,6 +9,7 @@ const i18nDuplicateCustomId: MessageIds = 'i18nDuplicateCustomId';
const suggestAddI18nAttribute: MessageIds = 'suggestAddI18nAttribute';
const i18nMissingDescription: MessageIds = 'i18nMissingDescription';
const i18nMissingMeaning: MessageIds = 'i18nMissingMeaning';
+const i18nMarkupInContent: MessageIds = 'i18nMarkupInContent';
export const valid = [
`
@@ -98,6 +99,11 @@ export const valid = [
{{ error.title }}
`,
+ `
+
+ { value, plural, =0 {No elements} =1 {111} }
+
+ `,
`
{ value, plural, =0 {No elements
} =1 {111} }
@@ -240,6 +246,39 @@ export const valid = [
`,
options: [{ ignoreTags: ['ng-template'] }],
},
+ {
+ code: `
+
+ Text to translate{{ Bound }}
+
+ `,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ },
+ {
+ code: `
+
+ Text to translate{{ Bound }}
+
+ `,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ },
+ {
+ code: `
+
+ { value, plural, =0 {No elements} =1 {111} }
+
+ `,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ },
+ {
+ code: `
+
+ { value, plural, =0 {
No elements
} =1 {111} }
+
+ `,
+ // ideally this would flag the markup inside the ICU, but it's not possible to detect that
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ },
// https://github.com/angular-eslint/angular-eslint/issues/701
`
`,
}),
+ convertAnnotatedSourceToFailureCase({
+ description: 'should fail if `i18n` attribute is missing on ICU',
+ annotatedSource: `
+ { value, plural, =0 {No elements} =1 {111} }
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId: i18nAttributeOnIcuOrText,
+ annotatedOutput: `
+ { value, plural, =0 {No elements} =1 {111} }
+
+ `,
+ }),
convertAnnotatedSourceToFailureCase({
description:
'should fail if `i18n` attribute is missing on element containing ICU',
@@ -1073,4 +1124,46 @@ export const invalid = [
`,
}),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail if `i18n` attribute is on element containing markup',
+ annotatedSource: `
+
+ Text to translate
+
+ ~~~~~~~~~~~
+
+ `,
+ messageId: i18nMarkupInContent,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail if `i18n` attribute is on `Template` containing markup',
+ annotatedSource: `
+
+ Text to translate
+
+ ~~~~~~~~~~~
+
+ `,
+ messageId: i18nMarkupInContent,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail if `i18n` attribute is on element containing markup, even if it also has `i18n` attribute',
+ annotatedSource: `
+
+
+ ~~~~~~~~~~
+ Text to translate
+ ~~~~~~~~~~~~~~~~~
+
+ ~~~~~~
+
+ `,
+ messageId: i18nMarkupInContent,
+ options: [{ allowMarkupInContent: false, checkId: false }],
+ }),
];