@blumintinc/eslint-plugin-blumint 1.20.65 → 1.20.67
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/lib/index.js +1 -1
- package/lib/rules/enforce-firestore-doc-ref-generic.js +59 -7
- package/lib/rules/enforce-microdiff.js +5 -0
- package/lib/rules/memo-nested-react-components.js +6 -2
- package/lib/rules/no-compositing-layer-props.js +4 -0
- package/lib/rules/no-jsx-in-hooks.js +5 -2
- package/lib/rules/optimize-object-boolean-conditions.d.ts +2 -1
- package/lib/rules/optimize-object-boolean-conditions.js +310 -35
- package/lib/rules/prefer-getter-over-parameterless-method.js +1 -1
- package/lib/rules/prefer-sx-prop-over-system-props.d.ts +1 -0
- package/lib/rules/prefer-sx-prop-over-system-props.js +499 -82
- package/lib/rules/prefer-union-from-const-array.d.ts +6 -1
- package/lib/rules/prefer-union-from-const-array.js +103 -9
- package/lib/utils/ruleTester.d.ts +26 -0
- package/lib/utils/ruleTester.js +28 -1
- package/package.json +1 -1
- package/release-manifest.json +85 -0
|
@@ -3,6 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferSxPropOverSystemProps = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
/**
|
|
7
|
+
* Matches Prettier's own default. The autofix rewrites JSX that a formatter
|
|
8
|
+
* owns, so a line it emits past this width is rewritten on the next
|
|
9
|
+
* `prettier --write` — and fails `prettier --check` in the meantime.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_PRINT_WIDTH = 80;
|
|
6
12
|
/**
|
|
7
13
|
* The canonical set of MUI system props that are deprecated in favor of `sx`.
|
|
8
14
|
* These are all props that MUI resolves via its system/styled engine and will
|
|
@@ -235,6 +241,491 @@ function attrValueToSxValue(attr, sourceCode) {
|
|
|
235
241
|
}
|
|
236
242
|
return sourceCode.getText(value);
|
|
237
243
|
}
|
|
244
|
+
/** The whitespace that indents the line containing `offset`. */
|
|
245
|
+
const indentationAt = (sourceCode, offset) => {
|
|
246
|
+
const text = sourceCode.getText();
|
|
247
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
248
|
+
const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
|
|
249
|
+
return match ? match[0] : '';
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* Ranges whose interior line breaks carry string data rather than formatting.
|
|
253
|
+
* A multi-line template literal (or a string spliced together with line
|
|
254
|
+
* continuations) evaluates to the whitespace written inside it, so shifting
|
|
255
|
+
* those lines would silently change the value the code produces — the same
|
|
256
|
+
* carve-out `use-latest-callback` makes for a relocated callback body.
|
|
257
|
+
*/
|
|
258
|
+
const stringDataRangesOf = (sourceCode, node) => sourceCode
|
|
259
|
+
.getTokens(node)
|
|
260
|
+
.filter((token) => (token.type === utils_1.AST_TOKEN_TYPES.Template ||
|
|
261
|
+
token.type === utils_1.AST_TOKEN_TYPES.String) &&
|
|
262
|
+
token.loc.start.line !== token.loc.end.line)
|
|
263
|
+
.map((token) => token.range);
|
|
264
|
+
/**
|
|
265
|
+
* A per-line transform moving text written at `fromIndent` to `toIndent`, or
|
|
266
|
+
* null when neither indentation is a prefix of the other (tabs against spaces),
|
|
267
|
+
* where no delta can be applied without corrupting the layout.
|
|
268
|
+
*/
|
|
269
|
+
const lineShifterBetween = (fromIndent, toIndent) => {
|
|
270
|
+
if (fromIndent === toIndent) {
|
|
271
|
+
return (line) => line;
|
|
272
|
+
}
|
|
273
|
+
if (fromIndent.startsWith(toIndent)) {
|
|
274
|
+
const removed = fromIndent.slice(toIndent.length);
|
|
275
|
+
return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
|
|
276
|
+
}
|
|
277
|
+
if (toIndent.startsWith(fromIndent)) {
|
|
278
|
+
const added = toIndent.slice(fromIndent.length);
|
|
279
|
+
return (line) => `${added}${line}`;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* A node's text with its continuation lines moved from the depth it was
|
|
285
|
+
* written at to `toIndent`. Null when the move is not expressible, which asks
|
|
286
|
+
* the caller to leave the source as the author wrote it.
|
|
287
|
+
*/
|
|
288
|
+
const reindentedText = (sourceCode, node, fromIndent, toIndent) => {
|
|
289
|
+
const text = sourceCode.getText(node);
|
|
290
|
+
if (!text.includes('\n')) {
|
|
291
|
+
return text;
|
|
292
|
+
}
|
|
293
|
+
const shiftLine = lineShifterBetween(fromIndent, toIndent);
|
|
294
|
+
if (!shiftLine) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const stringData = stringDataRangesOf(sourceCode, node);
|
|
298
|
+
const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
|
|
299
|
+
let offset = node.range[0];
|
|
300
|
+
return text
|
|
301
|
+
.split('\n')
|
|
302
|
+
.map((line, index) => {
|
|
303
|
+
const lineStart = offset;
|
|
304
|
+
offset += line.length + 1;
|
|
305
|
+
// The first line is spliced in after the property key, so it has no
|
|
306
|
+
// indentation of its own left to adjust.
|
|
307
|
+
if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
|
|
308
|
+
return line;
|
|
309
|
+
}
|
|
310
|
+
return shiftLine(line);
|
|
311
|
+
})
|
|
312
|
+
.join('\n');
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Ranges of the file's block comments. Their interior lines are excluded from
|
|
316
|
+
* the indentation census below, so only a comment's opening line — which does
|
|
317
|
+
* sit at the surrounding code's depth — is ever measured.
|
|
318
|
+
*/
|
|
319
|
+
const blockCommentRangesOf = (sourceCode) => sourceCode
|
|
320
|
+
.getAllComments()
|
|
321
|
+
.filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
|
|
322
|
+
.map((comment) => comment.range);
|
|
323
|
+
/**
|
|
324
|
+
* The file's own nesting step, taken as the most common indentation increase
|
|
325
|
+
* between consecutive lines. Reading it from the source keeps emitted code in
|
|
326
|
+
* the author's units instead of assuming a two-space, space-indented file.
|
|
327
|
+
*/
|
|
328
|
+
const indentUnitOf = (sourceCode) => {
|
|
329
|
+
const text = sourceCode.getText();
|
|
330
|
+
const blockComments = blockCommentRangesOf(sourceCode);
|
|
331
|
+
// A block comment's continuation lines align on the `*` one column in from
|
|
332
|
+
// the comment's own indentation. That is comment alignment, not a nesting
|
|
333
|
+
// step, and counting it makes any JSDoc-heavy file look 1-space indented.
|
|
334
|
+
const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
|
|
335
|
+
const frequencies = new Map();
|
|
336
|
+
let previous = '';
|
|
337
|
+
let offset = 0;
|
|
338
|
+
for (const line of text.split('\n')) {
|
|
339
|
+
const lineStart = offset;
|
|
340
|
+
offset += line.length + 1;
|
|
341
|
+
if (line.trim() === '')
|
|
342
|
+
continue;
|
|
343
|
+
if (continuesBlockComment(lineStart))
|
|
344
|
+
continue;
|
|
345
|
+
const match = /^[ \t]*/.exec(line);
|
|
346
|
+
const indent = match ? match[0] : '';
|
|
347
|
+
if (indent.length > previous.length && indent.startsWith(previous)) {
|
|
348
|
+
const delta = indent.slice(previous.length);
|
|
349
|
+
frequencies.set(delta, (frequencies.get(delta) ?? 0) + 1);
|
|
350
|
+
}
|
|
351
|
+
previous = indent;
|
|
352
|
+
}
|
|
353
|
+
let unit = ' ';
|
|
354
|
+
let best = 0;
|
|
355
|
+
for (const [delta, count] of frequencies) {
|
|
356
|
+
if (count > best) {
|
|
357
|
+
unit = delta;
|
|
358
|
+
best = count;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return unit;
|
|
362
|
+
};
|
|
363
|
+
/** The source with every planned edit applied, used to measure emitted lines. */
|
|
364
|
+
const applyEdits = (text, edits) => {
|
|
365
|
+
const ordered = [...edits].sort((a, b) => b.range[0] - a.range[0]);
|
|
366
|
+
let result = text;
|
|
367
|
+
for (const edit of ordered) {
|
|
368
|
+
result = `${result.slice(0, edit.range[0])}${edit.text}${result.slice(edit.range[1])}`;
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
};
|
|
372
|
+
const sxSlotOf = (sxAttr) => {
|
|
373
|
+
if (sxAttr === null) {
|
|
374
|
+
return { kind: 'new' };
|
|
375
|
+
}
|
|
376
|
+
const { value } = sxAttr;
|
|
377
|
+
if (value === null) {
|
|
378
|
+
// `<Box sx />` — there is no value to merge into.
|
|
379
|
+
return { kind: 'unsupported' };
|
|
380
|
+
}
|
|
381
|
+
if (value.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
|
|
382
|
+
const { expression } = value;
|
|
383
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
384
|
+
return { kind: 'object', object: expression };
|
|
385
|
+
}
|
|
386
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
387
|
+
return { kind: 'array', array: expression };
|
|
388
|
+
}
|
|
389
|
+
return { kind: 'spread', expression };
|
|
390
|
+
}
|
|
391
|
+
return { kind: 'spread', expression: value };
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* Plans every edit the autofix makes for one JSX element.
|
|
395
|
+
*
|
|
396
|
+
* Prettier keeps an author's decision to break an object literal across lines
|
|
397
|
+
* but rewrites any line past the print width, so under-wrapping is the only
|
|
398
|
+
* defect that matters here: every branch below emits the compact single-line
|
|
399
|
+
* form and only adds line breaks when that form would overflow, or when the
|
|
400
|
+
* object being merged into is already expanded.
|
|
401
|
+
*/
|
|
402
|
+
function planSxEdits(sourceCode, node, systemPropAttrs, sxAttr, printWidth) {
|
|
403
|
+
const source = sourceCode.getText();
|
|
404
|
+
const slot = sxSlotOf(sxAttr);
|
|
405
|
+
if (slot.kind === 'unsupported') {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
const anchor = systemPropAttrs[0];
|
|
409
|
+
const sxSlotNode = sxAttr ?? anchor;
|
|
410
|
+
const indentUnit = indentUnitOf(sourceCode);
|
|
411
|
+
const systemPropSet = new Set(systemPropAttrs);
|
|
412
|
+
/**
|
|
413
|
+
* The `key: value` pairs for the moved props. `targetIndent` is the depth the
|
|
414
|
+
* pairs are emitted at, which only matters for a value spanning several lines
|
|
415
|
+
* (a nested object, a template literal): its continuation lines have to move
|
|
416
|
+
* with it. Null asks for the value verbatim, as the single-line form leaves
|
|
417
|
+
* every pair on the line it already occupies.
|
|
418
|
+
*/
|
|
419
|
+
const renderEntries = (targetIndent) => {
|
|
420
|
+
const entries = [];
|
|
421
|
+
for (const attr of systemPropAttrs) {
|
|
422
|
+
const key = attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier
|
|
423
|
+
? attr.name.name
|
|
424
|
+
: sourceCode.getText(attr.name);
|
|
425
|
+
const rawValue = attrValueToSxValue(attr, sourceCode);
|
|
426
|
+
if (targetIndent === null || !rawValue.includes('\n')) {
|
|
427
|
+
entries.push(`${key}: ${rawValue}`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// Only an expression container's text comes straight from the source, so
|
|
431
|
+
// it is the only multi-line value whose lines can be located and moved.
|
|
432
|
+
const value = attr.value;
|
|
433
|
+
if (!value ||
|
|
434
|
+
value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer ||
|
|
435
|
+
value.expression.type === utils_1.AST_NODE_TYPES.JSXEmptyExpression) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
const moved = reindentedText(sourceCode, value.expression, indentationAt(sourceCode, attr.range[0]), targetIndent);
|
|
439
|
+
if (moved === null) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
entries.push(`${key}: ${moved}`);
|
|
443
|
+
}
|
|
444
|
+
return entries;
|
|
445
|
+
};
|
|
446
|
+
const inlineEntries = renderEntries(null) ?? [];
|
|
447
|
+
const joinedEntries = inlineEntries.join(', ');
|
|
448
|
+
/** Deleting the whitespace ahead of a prop keeps attributes from drifting apart. */
|
|
449
|
+
const removalOf = (attr) => {
|
|
450
|
+
let start = attr.range[0];
|
|
451
|
+
while (start > 0 && /\s/.test(source[start - 1])) {
|
|
452
|
+
start--;
|
|
453
|
+
}
|
|
454
|
+
return { range: [start, attr.range[1]], text: '' };
|
|
455
|
+
};
|
|
456
|
+
// The first system prop is reused as the insertion point when the element has
|
|
457
|
+
// no `sx` yet, so it is replaced rather than removed.
|
|
458
|
+
const removals = (sxAttr === null ? systemPropAttrs.slice(1) : systemPropAttrs).map(removalOf);
|
|
459
|
+
const emptyRangeAt = (offset) => [offset, offset];
|
|
460
|
+
const inlineEditFor = () => {
|
|
461
|
+
switch (slot.kind) {
|
|
462
|
+
case 'new':
|
|
463
|
+
return { range: anchor.range, text: `sx={{ ${joinedEntries} }}` };
|
|
464
|
+
case 'object': {
|
|
465
|
+
const first = slot.object.properties[0];
|
|
466
|
+
return first
|
|
467
|
+
? {
|
|
468
|
+
range: emptyRangeAt(first.range[0]),
|
|
469
|
+
text: `${joinedEntries}, `,
|
|
470
|
+
}
|
|
471
|
+
: { range: slot.object.range, text: `{ ${joinedEntries} }` };
|
|
472
|
+
}
|
|
473
|
+
case 'array': {
|
|
474
|
+
const first = slot.array.elements[0];
|
|
475
|
+
if (slot.array.elements.length === 0) {
|
|
476
|
+
return { range: slot.array.range, text: `[{ ${joinedEntries} }]` };
|
|
477
|
+
}
|
|
478
|
+
// A hole (`sx={[, base]}`) has no node to anchor an insertion on.
|
|
479
|
+
return first
|
|
480
|
+
? {
|
|
481
|
+
range: emptyRangeAt(first.range[0]),
|
|
482
|
+
text: `{ ${joinedEntries} }, `,
|
|
483
|
+
}
|
|
484
|
+
: null;
|
|
485
|
+
}
|
|
486
|
+
case 'spread':
|
|
487
|
+
return sxAttr
|
|
488
|
+
? {
|
|
489
|
+
range: sxAttr.range,
|
|
490
|
+
text: `sx={{ ${joinedEntries}, ...${sourceCode.getText(slot.expression)} }}`,
|
|
491
|
+
}
|
|
492
|
+
: null;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const inlineEdit = inlineEditFor();
|
|
496
|
+
if (inlineEdit === null) {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
const inlineEdits = [inlineEdit, ...removals];
|
|
500
|
+
/** The indentation of a node that starts its own line, else null. */
|
|
501
|
+
const ownLineIndentOf = (offset) => {
|
|
502
|
+
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
|
|
503
|
+
const lead = source.slice(lineStart, offset);
|
|
504
|
+
return lead.trim() === '' ? lead : null;
|
|
505
|
+
};
|
|
506
|
+
/**
|
|
507
|
+
* Whether the author already broke a literal open, which Prettier preserves.
|
|
508
|
+
* Merging into it has to follow that shape at any width; splicing onto the
|
|
509
|
+
* first member's line leaves the literal formatted two ways at once.
|
|
510
|
+
*/
|
|
511
|
+
const firstMemberOnOwnLine = (literal, first) => {
|
|
512
|
+
if (!source.slice(literal.range[0] + 1, first.range[0]).includes('\n')) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
return ownLineIndentOf(first.range[0]);
|
|
516
|
+
};
|
|
517
|
+
const objectText = (parts, indent, expand) => expand
|
|
518
|
+
? `{\n${parts
|
|
519
|
+
.map((part) => `${indent}${indentUnit}${part},`)
|
|
520
|
+
.join('\n')}\n${indent}}`
|
|
521
|
+
: `{ ${parts.join(', ')} }`;
|
|
522
|
+
/** Existing members reproduced verbatim, or null when any spans lines. */
|
|
523
|
+
const verbatimTextsOf = (nodes) => {
|
|
524
|
+
const texts = [];
|
|
525
|
+
for (const member of nodes) {
|
|
526
|
+
if (!member) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
const text = sourceCode.getText(member);
|
|
530
|
+
if (text.includes('\n')) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
texts.push(text);
|
|
534
|
+
}
|
|
535
|
+
return texts;
|
|
536
|
+
};
|
|
537
|
+
/** The object literal for the moved props, expanded only when it must be. */
|
|
538
|
+
const objectAt = (indent) => {
|
|
539
|
+
const inline = `{ ${joinedEntries} }`;
|
|
540
|
+
if (!inline.includes('\n') && indent.length + inline.length <= printWidth) {
|
|
541
|
+
return inline;
|
|
542
|
+
}
|
|
543
|
+
const parts = renderEntries(`${indent}${indentUnit}`);
|
|
544
|
+
return parts === null ? null : objectText(parts, indent, true);
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* The whole rewritten `sx={...}` attribute rendered at `indent`, breaking the
|
|
548
|
+
* literal open only when the compact form would overflow the print width.
|
|
549
|
+
*/
|
|
550
|
+
const sxAttributeText = (indent) => {
|
|
551
|
+
if (slot.kind === 'array') {
|
|
552
|
+
const existing = verbatimTextsOf(slot.array.elements);
|
|
553
|
+
if (existing === null) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const inline = `sx={[${[`{ ${joinedEntries} }`, ...existing].join(', ')}]}`;
|
|
557
|
+
if (!inline.includes('\n') &&
|
|
558
|
+
indent.length + inline.length <= printWidth) {
|
|
559
|
+
return inline;
|
|
560
|
+
}
|
|
561
|
+
const elementIndent = `${indent}${indentUnit}`;
|
|
562
|
+
const object = objectAt(elementIndent);
|
|
563
|
+
if (object === null) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
const elements = [object, ...existing]
|
|
567
|
+
.map((element) => `${elementIndent}${element},`)
|
|
568
|
+
.join('\n');
|
|
569
|
+
return `sx={[\n${elements}\n${indent}]}`;
|
|
570
|
+
}
|
|
571
|
+
const trailing = slot.kind === 'object'
|
|
572
|
+
? verbatimTextsOf(slot.object.properties)
|
|
573
|
+
: slot.kind === 'spread'
|
|
574
|
+
? [`...${sourceCode.getText(slot.expression)}`]
|
|
575
|
+
: [];
|
|
576
|
+
if (trailing === null || trailing.some((text) => text.includes('\n'))) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const inline = `sx={${objectText([...inlineEntries, ...trailing], indent, false)}}`;
|
|
580
|
+
if (!inline.includes('\n') && indent.length + inline.length <= printWidth) {
|
|
581
|
+
return inline;
|
|
582
|
+
}
|
|
583
|
+
const parts = renderEntries(`${indent}${indentUnit}`);
|
|
584
|
+
if (parts === null) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
return `sx={${objectText([...parts, ...trailing], indent, true)}}`;
|
|
588
|
+
};
|
|
589
|
+
/**
|
|
590
|
+
* Merging into a literal the author already expanded: one new member per
|
|
591
|
+
* line, at the indentation the existing members sit at.
|
|
592
|
+
*/
|
|
593
|
+
const expandedMergeEdits = () => {
|
|
594
|
+
if (slot.kind === 'object') {
|
|
595
|
+
const first = slot.object.properties[0];
|
|
596
|
+
if (!first) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const indent = firstMemberOnOwnLine(slot.object, first);
|
|
600
|
+
if (indent === null) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const parts = renderEntries(indent);
|
|
604
|
+
if (parts === null) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
return [
|
|
608
|
+
{
|
|
609
|
+
range: emptyRangeAt(first.range[0]),
|
|
610
|
+
text: parts.map((part) => `${part},\n${indent}`).join(''),
|
|
611
|
+
},
|
|
612
|
+
...removals,
|
|
613
|
+
];
|
|
614
|
+
}
|
|
615
|
+
if (slot.kind === 'array') {
|
|
616
|
+
const first = slot.array.elements[0];
|
|
617
|
+
if (!first) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const indent = firstMemberOnOwnLine(slot.array, first);
|
|
621
|
+
if (indent === null) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
const object = objectAt(indent);
|
|
625
|
+
if (object === null) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
return [
|
|
629
|
+
{
|
|
630
|
+
range: emptyRangeAt(first.range[0]),
|
|
631
|
+
text: `${object},\n${indent}`,
|
|
632
|
+
},
|
|
633
|
+
...removals,
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
};
|
|
638
|
+
const expandedMerge = expandedMergeEdits();
|
|
639
|
+
if (expandedMerge) {
|
|
640
|
+
return expandedMerge;
|
|
641
|
+
}
|
|
642
|
+
// Measure the line the compact form actually lands on, with every removal
|
|
643
|
+
// already applied, rather than guessing at the element's final shape.
|
|
644
|
+
const simulated = applyEdits(source, inlineEdits);
|
|
645
|
+
const shiftBefore = (offset) => inlineEdits
|
|
646
|
+
.filter((edit) => edit.range[1] <= offset)
|
|
647
|
+
.reduce((total, edit) => total + edit.text.length - (edit.range[1] - edit.range[0]), 0);
|
|
648
|
+
const slotStart = sxSlotNode.range[0] + shiftBefore(sxSlotNode.range[0]);
|
|
649
|
+
const slotEnd = sxSlotNode.range[1] + shiftBefore(sxSlotNode.range[1]);
|
|
650
|
+
const lineStart = simulated.lastIndexOf('\n', slotStart - 1) + 1;
|
|
651
|
+
const lineBreak = simulated.indexOf('\n', slotStart);
|
|
652
|
+
const lineEnd = lineBreak === -1 ? simulated.length : lineBreak;
|
|
653
|
+
// A prop whose value already spans lines cannot sit in a one-line object: the
|
|
654
|
+
// literal would open on one line and close on another with its remaining
|
|
655
|
+
// entries crammed against the value. Width says nothing here, because the
|
|
656
|
+
// measured line stops at the value's first break.
|
|
657
|
+
const hasMultilineEntry = inlineEntries.some((entry) => entry.includes('\n'));
|
|
658
|
+
if (!hasMultilineEntry && lineEnd - lineStart <= printWidth) {
|
|
659
|
+
return inlineEdits;
|
|
660
|
+
}
|
|
661
|
+
// The attribute may end several lines below where it starts once a multi-line
|
|
662
|
+
// value is folded in, so ownership is judged against the line it ends on.
|
|
663
|
+
const slotLineBreak = simulated.indexOf('\n', slotEnd);
|
|
664
|
+
const slotLineEnd = slotLineBreak === -1 ? simulated.length : slotLineBreak;
|
|
665
|
+
const attributeOwnsLine = simulated.slice(lineStart, slotStart).trim() === '' &&
|
|
666
|
+
simulated.slice(slotEnd, slotLineEnd).trim() === '';
|
|
667
|
+
if (attributeOwnsLine) {
|
|
668
|
+
const rewritten = sxAttributeText(simulated.slice(lineStart, slotStart));
|
|
669
|
+
if (rewritten !== null) {
|
|
670
|
+
return [{ range: sxSlotNode.range, text: rewritten }, ...removals];
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* An element whose attributes all share one line has no room left for the
|
|
675
|
+
* merged `sx`; Prettier's answer is to give every attribute its own line,
|
|
676
|
+
* which is reproduced here so the emitted element is already in the shape the
|
|
677
|
+
* formatter would put it in.
|
|
678
|
+
*/
|
|
679
|
+
const restructuredElement = () => {
|
|
680
|
+
if (node.loc.start.line !== node.loc.end.line) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
// Rebuilding from the attribute list would drop anything the list does not
|
|
684
|
+
// cover, and would leave a trailing fragment of an unrelated statement.
|
|
685
|
+
if (sourceCode.getCommentsInside(node).length > 0) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
const elementIndent = ownLineIndentOf(node.range[0]);
|
|
689
|
+
if (elementIndent === null) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
const tailBreak = source.indexOf('\n', node.range[1]);
|
|
693
|
+
const tail = source.slice(node.range[1], tailBreak === -1 ? source.length : tailBreak);
|
|
694
|
+
if (tail.trim() !== '') {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
const first = node.attributes[0];
|
|
698
|
+
if (!first) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
const attributeIndent = `${elementIndent}${indentUnit}`;
|
|
702
|
+
const attributes = [];
|
|
703
|
+
for (const attr of node.attributes) {
|
|
704
|
+
if (attr === sxSlotNode) {
|
|
705
|
+
const rewritten = sxAttributeText(attributeIndent);
|
|
706
|
+
if (rewritten === null) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
attributes.push(rewritten);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (systemPropSet.has(attr)) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
attributes.push(sourceCode.getText(attr));
|
|
716
|
+
}
|
|
717
|
+
const head = source.slice(node.range[0], first.range[0]).trimEnd();
|
|
718
|
+
const body = attributes
|
|
719
|
+
.map((attribute) => `${attributeIndent}${attribute}`)
|
|
720
|
+
.join('\n');
|
|
721
|
+
return `${head}\n${body}\n${elementIndent}${node.selfClosing ? '/>' : '>'}`;
|
|
722
|
+
};
|
|
723
|
+
const restructured = restructuredElement();
|
|
724
|
+
if (restructured !== null) {
|
|
725
|
+
return [{ range: node.range, text: restructured }];
|
|
726
|
+
}
|
|
727
|
+
return inlineEdits;
|
|
728
|
+
}
|
|
238
729
|
exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
239
730
|
name: 'prefer-sx-prop-over-system-props',
|
|
240
731
|
meta: {
|
|
@@ -256,6 +747,10 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
256
747
|
type: 'array',
|
|
257
748
|
items: { type: 'string' },
|
|
258
749
|
},
|
|
750
|
+
printWidth: {
|
|
751
|
+
type: 'number',
|
|
752
|
+
minimum: 1,
|
|
753
|
+
},
|
|
259
754
|
},
|
|
260
755
|
additionalProperties: false,
|
|
261
756
|
},
|
|
@@ -272,6 +767,9 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
272
767
|
const extraAllowed = options.allowedProps
|
|
273
768
|
? new Set(options.allowedProps)
|
|
274
769
|
: new Set();
|
|
770
|
+
const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
|
|
771
|
+
? options.printWidth
|
|
772
|
+
: DEFAULT_PRINT_WIDTH;
|
|
275
773
|
function isAllowedProp(name) {
|
|
276
774
|
if (DEFAULT_ALLOWED_PROPS.has(name))
|
|
277
775
|
return true;
|
|
@@ -319,21 +817,6 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
319
817
|
if (systemPropAttrs.length === 0)
|
|
320
818
|
return;
|
|
321
819
|
const sourceCode = context.getSourceCode();
|
|
322
|
-
const fullSource = sourceCode.getText();
|
|
323
|
-
/**
|
|
324
|
-
* Remove an attribute AND the whitespace that precedes it so the
|
|
325
|
-
* output does not accumulate extra spaces between attributes.
|
|
326
|
-
*/
|
|
327
|
-
function removeAttrWithLeadingSpace(fixer, attr) {
|
|
328
|
-
const range = attr.range ?? [0, 0];
|
|
329
|
-
const [start, end] = range;
|
|
330
|
-
// Walk backwards from the attribute start to consume any whitespace
|
|
331
|
-
let wsStart = start;
|
|
332
|
-
while (wsStart > 0 && /\s/.test(fullSource[wsStart - 1])) {
|
|
333
|
-
wsStart--;
|
|
334
|
-
}
|
|
335
|
-
return fixer.removeRange([wsStart, end]);
|
|
336
|
-
}
|
|
337
820
|
// Report each system prop. Only the first carries the fixer to avoid
|
|
338
821
|
// overlapping fix ranges on the same element.
|
|
339
822
|
systemPropAttrs.forEach((attr, index) => {
|
|
@@ -345,73 +828,7 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
345
828
|
messageId: 'preferSxProp',
|
|
346
829
|
data: { prop: propName },
|
|
347
830
|
fix: index === 0
|
|
348
|
-
? (fixer) =>
|
|
349
|
-
const sc = sourceCode;
|
|
350
|
-
const newEntries = systemPropAttrs
|
|
351
|
-
.map((a) => {
|
|
352
|
-
const key = a.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier
|
|
353
|
-
? a.name.name
|
|
354
|
-
: sc.getText(a.name);
|
|
355
|
-
const val = attrValueToSxValue(a, sc);
|
|
356
|
-
return `${key}: ${val}`;
|
|
357
|
-
})
|
|
358
|
-
.join(', ');
|
|
359
|
-
const fixes = [];
|
|
360
|
-
if (sxAttr === null) {
|
|
361
|
-
// No existing sx — replace the first system prop with sx={{...}}
|
|
362
|
-
// and delete the rest (including their leading whitespace).
|
|
363
|
-
fixes.push(fixer.replaceText(systemPropAttrs[0], `sx={{ ${newEntries} }}`));
|
|
364
|
-
for (let i = 1; i < systemPropAttrs.length; i++) {
|
|
365
|
-
fixes.push(removeAttrWithLeadingSpace(fixer, systemPropAttrs[i]));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
else if (sxAttr.value?.type ===
|
|
369
|
-
utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
370
|
-
sxAttr.value.expression.type ===
|
|
371
|
-
utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
372
|
-
// Existing sx={{ ... }} — merge system props at the front.
|
|
373
|
-
const existingObj = sxAttr.value.expression;
|
|
374
|
-
const existingProps = existingObj.properties;
|
|
375
|
-
if (existingProps.length === 0) {
|
|
376
|
-
fixes.push(fixer.replaceText(existingObj, `{ ${newEntries} }`));
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
fixes.push(fixer.insertTextBefore(existingProps[0], `${newEntries}, `));
|
|
380
|
-
}
|
|
381
|
-
for (const a of systemPropAttrs) {
|
|
382
|
-
fixes.push(removeAttrWithLeadingSpace(fixer, a));
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
else if (sxAttr.value?.type ===
|
|
386
|
-
utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
387
|
-
sxAttr.value.expression.type ===
|
|
388
|
-
utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
389
|
-
// sx={[...]} — prepend a new object as the first array element.
|
|
390
|
-
const arr = sxAttr.value.expression;
|
|
391
|
-
if (arr.elements.length === 0) {
|
|
392
|
-
fixes.push(fixer.replaceText(arr, `[{ ${newEntries} }]`));
|
|
393
|
-
}
|
|
394
|
-
else {
|
|
395
|
-
fixes.push(fixer.insertTextBefore(arr.elements[0], `{ ${newEntries} }, `));
|
|
396
|
-
}
|
|
397
|
-
for (const a of systemPropAttrs) {
|
|
398
|
-
fixes.push(removeAttrWithLeadingSpace(fixer, a));
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
else if (sxAttr.value !== null) {
|
|
402
|
-
// sx is a variable/expression — spread it: sx={{ entries, ...expr }}
|
|
403
|
-
const innerExpr = sxAttr.value.type ===
|
|
404
|
-
utils_1.AST_NODE_TYPES.JSXExpressionContainer
|
|
405
|
-
? sc.getText(sxAttr.value
|
|
406
|
-
.expression)
|
|
407
|
-
: sc.getText(sxAttr.value);
|
|
408
|
-
fixes.push(fixer.replaceText(sxAttr, `sx={{ ${newEntries}, ...${innerExpr} }}`));
|
|
409
|
-
for (const a of systemPropAttrs) {
|
|
410
|
-
fixes.push(removeAttrWithLeadingSpace(fixer, a));
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
return fixes;
|
|
414
|
-
}
|
|
831
|
+
? (fixer) => planSxEdits(sourceCode, node, systemPropAttrs, sxAttr, printWidth).map((edit) => fixer.replaceTextRange(edit.range, edit.text))
|
|
415
832
|
: null,
|
|
416
833
|
});
|
|
417
834
|
});
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
-
|
|
2
|
+
type Options = [
|
|
3
|
+
{
|
|
4
|
+
printWidth?: number;
|
|
5
|
+
}
|
|
6
|
+
];
|
|
7
|
+
export declare const preferUnionFromConstArray: TSESLint.RuleModule<"preferDerivedUnion", Options, TSESLint.RuleListener>;
|
|
3
8
|
export default preferUnionFromConstArray;
|