template-indent.js 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. 'use strict';
  2. const stripIndent = require('strip-indent');
  3. const indentString = require('indent-string');
  4. const esquery = require('esquery');
  5. const {replaceTemplateElement} = require('./fix/index.js');
  6. const MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE = 'template-indent';
  7. const messages = {
  8. [MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE]: 'Templates should be properly indented.',
  9. };
  10. /** @param {import('eslint').Rule.RuleContext} context */
  11. const create = context => {
  12. const sourceCode = context.getSourceCode();
  13. const options = {
  14. tags: ['outdent', 'dedent', 'gql', 'sql', 'html', 'styled'],
  15. functions: ['dedent', 'stripIndent'],
  16. selectors: [],
  17. comments: ['HTML', 'indent'],
  18. ...context.options[0],
  19. };
  20. options.comments = options.comments.map(comment => comment.toLowerCase());
  21. const selectors = [
  22. ...options.tags.map(tag => `TaggedTemplateExpression[tag.name="${tag}"] > .quasi`),
  23. ...options.functions.map(fn => `CallExpression[callee.name="${fn}"] > .arguments`),
  24. ...options.selectors,
  25. ];
  26. /** @param {import('@babel/core').types.TemplateLiteral} node */
  27. const indentTemplateLiteralNode = node => {
  28. const delimiter = '__PLACEHOLDER__' + Math.random();
  29. const joined = node.quasis
  30. .map(quasi => {
  31. const untrimmedText = sourceCode.getText(quasi);
  32. return untrimmedText.slice(1, quasi.tail ? -1 : -2);
  33. })
  34. .join(delimiter);
  35. const eolMatch = joined.match(/\r?\n/);
  36. if (!eolMatch) {
  37. return;
  38. }
  39. const eol = eolMatch[0];
  40. const startLine = sourceCode.lines[node.loc.start.line - 1];
  41. const marginMatch = startLine.match(/^(\s*)\S/);
  42. const parentMargin = marginMatch ? marginMatch[1] : '';
  43. let indent;
  44. if (typeof options.indent === 'string') {
  45. indent = options.indent;
  46. } else if (typeof options.indent === 'number') {
  47. indent = ' '.repeat(options.indent);
  48. } else {
  49. const tabs = parentMargin.startsWith('\t');
  50. indent = tabs ? '\t' : ' ';
  51. }
  52. const dedented = stripIndent(joined);
  53. const fixed
  54. = eol
  55. + indentString(dedented.trim(), 1, {indent: parentMargin + indent})
  56. + eol
  57. + parentMargin;
  58. if (fixed === joined) {
  59. return;
  60. }
  61. context.report({
  62. node,
  63. messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
  64. fix: fixer => fixed
  65. .split(delimiter)
  66. .map((replacement, index) => replaceTemplateElement(fixer, node.quasis[index], replacement)),
  67. });
  68. };
  69. return {
  70. /** @param {import('@babel/core').types.TemplateLiteral} node */
  71. TemplateLiteral: node => {
  72. if (options.comments.length > 0) {
  73. const previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
  74. if (previousToken && previousToken.type === 'Block' && options.comments.includes(previousToken.value.trim().toLowerCase())) {
  75. indentTemplateLiteralNode(node);
  76. return;
  77. }
  78. }
  79. const ancestry = context.getAncestors().reverse();
  80. const shouldIndent = selectors.some(selector => esquery.matches(node, esquery.parse(selector), ancestry));
  81. if (shouldIndent) {
  82. indentTemplateLiteralNode(node);
  83. }
  84. },
  85. };
  86. };
  87. /** @type {import('json-schema').JSONSchema7[]} */
  88. const schema = [
  89. {
  90. type: 'object',
  91. additionalProperties: false,
  92. properties: {
  93. indent: {
  94. oneOf: [
  95. {
  96. type: 'string',
  97. pattern: /^\s+$/.source,
  98. },
  99. {
  100. type: 'integer',
  101. minimum: 1,
  102. },
  103. ],
  104. },
  105. tags: {
  106. type: 'array',
  107. uniqueItems: true,
  108. items: {
  109. type: 'string',
  110. },
  111. },
  112. functions: {
  113. type: 'array',
  114. uniqueItems: true,
  115. items: {
  116. type: 'string',
  117. },
  118. },
  119. selectors: {
  120. type: 'array',
  121. uniqueItems: true,
  122. items: {
  123. type: 'string',
  124. },
  125. },
  126. comments: {
  127. type: 'array',
  128. uniqueItems: true,
  129. items: {
  130. type: 'string',
  131. },
  132. },
  133. },
  134. },
  135. ];
  136. /** @type {import('eslint').Rule.RuleModule} */
  137. module.exports = {
  138. create,
  139. meta: {
  140. type: 'suggestion',
  141. docs: {
  142. description: 'Fix whitespace-insensitive template indentation.',
  143. },
  144. fixable: 'code',
  145. schema,
  146. messages,
  147. },
  148. };