@taiga-ui/eslint-plugin-experience-next 0.451.0 → 0.452.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -323,8 +323,51 @@ const c = a + b;
323
323
  const c = `${a}${b}`;
324
324
  ```
325
325
 
326
+ When the concatenation is a **direct expression inside a template literal**, the parts are inlined into the outer
327
+ template instead of producing a nested template literal:
328
+
329
+ ```ts
330
+ // ❌ error
331
+ const url = `${base}${path + query}`;
332
+
333
+ // ✅ after autofix — inlined, no nesting
334
+ const url = `${base}${path}${query}`;
335
+ ```
336
+
337
+ ```ts
338
+ // ❌ error — literal concat inside template
339
+ const mask = `${'HH' + ':MM'}`;
340
+
341
+ // ✅ after autofix
342
+ const mask = `HH:MM`;
343
+ ```
344
+
345
+ When the concatenation appears **inside a method call or other expression** within a template literal, the rule skips it
346
+ to avoid creating unreadable nested template literals like `` `${`${a}${b}`.method()}` ``.
347
+
348
+ The rule also **flattens already-nested template literals** produced by earlier autofixes or written by hand:
349
+
350
+ ```ts
351
+ // ❌ error
352
+ const s = `${`${dateMode}${dateTimeSeparator}`}HH:MM`;
353
+
354
+ // ✅ after autofix
355
+ const s = `${dateMode}${dateTimeSeparator}HH:MM`;
356
+ ```
357
+
358
+ Concatenation that uses **inline comments between parts** is intentionally left untouched, as the comments serve as
359
+ documentation:
360
+
361
+ ```ts
362
+ // ✅ not flagged — comments are preserved
363
+ const urlRegex =
364
+ String.raw`^([a-zA-Z]+:\/\/)?` + // protocol
365
+ String.raw`([\w-]+\.)+[\w]{2,}` + // domain
366
+ String.raw`(\/\S*)?$`; // path
367
+ ```
368
+
326
369
  > For mixed concatenation (`'prefix' + variable`) use the standard `prefer-template` rule, which is already enabled in
327
- > `recommended`. Template literals (`` `foo` + `bar` ``) are not flagged by this rule.
370
+ > `recommended`. Template literals (`` `foo` + `bar` ``) and tagged templates are not flagged by this rule.
328
371
 
329
372
  ---
330
373
 
package/index.d.ts CHANGED
@@ -40,7 +40,7 @@ declare const plugin: {
40
40
  'no-playwright-empty-fill': import("@typescript-eslint/utils/ts-eslint").RuleModule<"useClear", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
41
41
  name: string;
42
42
  };
43
- 'no-string-literal-concat': import("@typescript-eslint/utils/ts-eslint").RuleModule<"mergeLiterals" | "useTemplate", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
43
+ 'no-string-literal-concat': import("@typescript-eslint/utils/ts-eslint").RuleModule<"flattenTemplate" | "mergeLiterals" | "useTemplate", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
44
44
  name: string;
45
45
  };
46
46
  'object-single-line': import("@typescript-eslint/utils/ts-eslint").RuleModule<"oneLine", [{
package/index.esm.js CHANGED
@@ -2180,7 +2180,7 @@ function isPlaywrightLocatorType(type) {
2180
2180
  }
2181
2181
 
2182
2182
  const createRule$6 = ESLintUtils.RuleCreator((name) => name);
2183
- function isStringLiteralNode(node) {
2183
+ function isStringLiteral(node) {
2184
2184
  return (node.type === AST_NODE_TYPES.Literal &&
2185
2185
  typeof node.value === 'string');
2186
2186
  }
@@ -2212,19 +2212,36 @@ function buildMergedString(parts) {
2212
2212
  .replaceAll(new RegExp(quote, 'g'), `\\${quote}`);
2213
2213
  return `${quote}${escaped}${quote}`;
2214
2214
  }
2215
- function buildTemplateLiteral(parts, getText) {
2216
- const inner = parts
2217
- .map((part) => {
2218
- if (isStringLiteralNode(part)) {
2219
- return part.value
2220
- .replaceAll('\\', '\\\\')
2221
- .replaceAll('`', '\\`')
2222
- .replaceAll('${', '\\${');
2223
- }
2224
- return `\${${getText(part)}}`;
2225
- })
2215
+ function escapeForTemplateLiteral(value) {
2216
+ return value.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${');
2217
+ }
2218
+ function partsToTemplateContent(parts, getText) {
2219
+ return parts
2220
+ .map((part) => isStringLiteral(part)
2221
+ ? escapeForTemplateLiteral(part.value)
2222
+ : `\${${getText(part)}}`)
2223
+ .join('');
2224
+ }
2225
+ /**
2226
+ * Returns the raw content between the backticks of a TemplateLiteral,
2227
+ * delegating each expression slot to `renderExpr`.
2228
+ */
2229
+ function templateContent(template, renderExpr) {
2230
+ return template.quasis
2231
+ .map((quasi, i) => `${quasi.value.raw}${i < template.expressions.length
2232
+ ? renderExpr(template.expressions[i], i)
2233
+ : ''}`)
2226
2234
  .join('');
2227
- return `\`${inner}\``;
2235
+ }
2236
+ function hasTemplateLiteralAncestor(node) {
2237
+ let current = node.parent;
2238
+ while (current != null) {
2239
+ if (current.type === AST_NODE_TYPES.TemplateLiteral) {
2240
+ return true;
2241
+ }
2242
+ current = current.parent;
2243
+ }
2244
+ return false;
2228
2245
  }
2229
2246
  const rule$4 = createRule$6({
2230
2247
  create(context) {
@@ -2239,46 +2256,74 @@ const rule$4 = createRule$6({
2239
2256
  // Type checking not available — only literal concatenation will be checked
2240
2257
  }
2241
2258
  function isStringNode(node) {
2242
- if (isStringLiteralNode(node)) {
2259
+ if (isStringLiteral(node)) {
2243
2260
  return true;
2244
2261
  }
2245
2262
  if (!parserServices || !checker) {
2246
2263
  return false;
2247
2264
  }
2248
2265
  const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
2249
- const type = checker.getTypeAtLocation(tsNode);
2250
- return isStringType(type, checker);
2266
+ return isStringType(checker.getTypeAtLocation(tsNode), checker);
2251
2267
  }
2268
+ const getText = (n) => sourceCode.getText(n);
2269
+ const wrapExpr = (expr) => `\${${getText(expr)}}`;
2252
2270
  return {
2253
2271
  BinaryExpression(node) {
2254
- if (node.operator !== '+') {
2272
+ if (node.operator !== '+' || !isRootConcat(node)) {
2255
2273
  return;
2256
2274
  }
2257
- if (!isRootConcat(node)) {
2275
+ // Comments between parts serve as inline documentation — preserve them
2276
+ if (sourceCode.getCommentsInside(node).length > 0) {
2258
2277
  return;
2259
2278
  }
2260
2279
  const parts = collectParts(node);
2261
- const allLiterals = parts.every(isStringLiteralNode);
2262
- if (allLiterals) {
2263
- context.report({
2264
- fix(fixer) {
2265
- return fixer.replaceText(node, buildMergedString(parts));
2266
- },
2267
- messageId: 'mergeLiterals',
2268
- node,
2269
- });
2280
+ if (!parts.every((p) => p.type !== AST_NODE_TYPES.TemplateLiteral && isStringNode(p))) {
2270
2281
  return;
2271
2282
  }
2272
- if (parts.every((part) => part.type !== AST_NODE_TYPES.TemplateLiteral &&
2273
- isStringNode(part))) {
2283
+ const allLiterals = parts.every(isStringLiteral);
2284
+ // Direct child of a template expression → inline parts to avoid
2285
+ // nested template literals like `${`${a}${b}`}`
2286
+ if (node.parent.type === AST_NODE_TYPES.TemplateLiteral) {
2287
+ const template = node.parent;
2288
+ // Tagged templates: changing quasis/expressions count alters behaviour
2289
+ if (template.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
2290
+ return;
2291
+ }
2274
2292
  context.report({
2275
- fix(fixer) {
2276
- return fixer.replaceText(node, buildTemplateLiteral(parts, (n) => sourceCode.getText(n)));
2277
- },
2278
- messageId: 'useTemplate',
2293
+ fix: (fixer) => fixer.replaceText(template, `\`${templateContent(template, (expr) => (expr === node ? partsToTemplateContent(parts, getText) : wrapExpr(expr)))}\``),
2294
+ messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
2279
2295
  node,
2280
2296
  });
2297
+ return;
2298
+ }
2299
+ // Nested inside a template but not direct child — would produce
2300
+ // `${`${a}${b}`.method()}`, so skip
2301
+ if (hasTemplateLiteralAncestor(node)) {
2302
+ return;
2281
2303
  }
2304
+ context.report({
2305
+ fix: (fixer) => fixer.replaceText(node, allLiterals
2306
+ ? buildMergedString(parts)
2307
+ : `\`${partsToTemplateContent(parts, getText)}\``),
2308
+ messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
2309
+ node,
2310
+ });
2311
+ },
2312
+ TemplateLiteral(node) {
2313
+ // Tagged templates: changing quasis/expressions count alters behaviour
2314
+ if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
2315
+ return;
2316
+ }
2317
+ node.expressions.forEach((expr, i) => {
2318
+ if (expr.type === AST_NODE_TYPES.TemplateLiteral &&
2319
+ expr.parent.type !== AST_NODE_TYPES.TaggedTemplateExpression) {
2320
+ context.report({
2321
+ fix: (fixer) => fixer.replaceText(node, `\`${templateContent(node, (e, j) => (j === i ? templateContent(expr, wrapExpr) : wrapExpr(e)))}\``),
2322
+ messageId: 'flattenTemplate',
2323
+ node: expr,
2324
+ });
2325
+ }
2326
+ });
2282
2327
  },
2283
2328
  };
2284
2329
  },
@@ -2288,6 +2333,7 @@ const rule$4 = createRule$6({
2288
2333
  },
2289
2334
  fixable: 'code',
2290
2335
  messages: {
2336
+ flattenTemplate: 'Flatten nested template literal into its parent.',
2291
2337
  mergeLiterals: 'Merge string literals instead of concatenating them.',
2292
2338
  useTemplate: 'Use a template literal instead of string concatenation.',
2293
2339
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ui/eslint-plugin-experience-next",
3
- "version": "0.451.0",
3
+ "version": "0.452.0",
4
4
  "description": "An ESLint plugin to enforce a consistent code styles across taiga-ui projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  import { ESLintUtils } from '@typescript-eslint/utils';
2
- type MessageId = 'mergeLiterals' | 'useTemplate';
2
+ type MessageId = 'flattenTemplate' | 'mergeLiterals' | 'useTemplate';
3
3
  export declare const rule: ESLintUtils.RuleModule<MessageId, [], unknown, ESLintUtils.RuleListener> & {
4
4
  name: string;
5
5
  };