Repository: rrd108/vue-mess-detector
Pull request #128 (2024-08-10 21:53:45)

feat: add vue-strong multi attribute element rule plus fixes


Patch:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 5d6c8b1..ce95c73 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -54,6 +54,7 @@ export default defineConfig({
                 { text: 'Self Closing Components', link: '/rules/vue-strong/self-closing-components' },
                 { text: 'Simple Computed Properties', link: '/rules/vue-strong/simple-computed' },
                 { text: 'Template Simple Expressions', link: '/rules/vue-strong/template-simple-expression' },
+                { text: 'Multi Attribute Elements', link: '/rules/vue-strong/multi-attribute-elements' },
               ] },
               { text: 'Vue Recommended', link: '/rules/vue-recommended', collapsed: true, items: [
                 { text: 'Element Attribute Order', link: '/rules/vue-recommended/element-attribute-order' },
diff --git a/docs/rules/vue-strong/index.md b/docs/rules/vue-strong/index.md
index 97f30b4..56dced6 100644
--- a/docs/rules/vue-strong/index.md
+++ b/docs/rules/vue-strong/index.md
@@ -11,3 +11,4 @@ These rules have been found to improve readability and/or developer experience i
 - [Self Closing Components](./self-closing-components.md)
 - [Simple Computed Properties](./simple-computed.md)
 - [Template Simple Expressions](./template-simple-expression.md)
+- [Multi Attribute Elements](./multi-attribute-elements.md)
diff --git a/docs/rules/vue-strong/multi-attribute-elements.md b/docs/rules/vue-strong/multi-attribute-elements.md
new file mode 100644
index 0000000..f8f2cfb
--- /dev/null
+++ b/docs/rules/vue-strong/multi-attribute-elements.md
@@ -0,0 +1,11 @@
+# Multi-Attribute Elements
+
+Checks that elements with multiple attributes span multiple lines, with one attribute per line.
+👉 https://vuejs.org/style-guide/rules-strongly-recommended.html#multi-attribute-elements
+
+## ❓ Why it's good to follow this rule?
+
+- **Readability**: Spreading attributes across multiple lines makes it easier to read and understand the code.
+- **Maintainability**: When attributes are on separate lines, it's simpler to add, remove, or modify them without affecting other attributes.
+- **Consistency**: Following this convention helps maintain a consistent style across components, making it easier for teams to collaborate.
+- **Debugging**: Having one attribute per line makes it easier to identify issues related to specific attributes, especially when debugging.
diff --git a/src/rules/rrd/plainScript.ts b/src/rules/rrd/plainScript.ts
index 96696c4..f50d498 100644
--- a/src/rules/rrd/plainScript.ts
+++ b/src/rules/rrd/plainScript.ts
@@ -5,7 +5,7 @@ import type { FileCheckResult, Offense } from '../../types'
 const results: FileCheckResult[] = []
 
 const checkPlainScript = (script: SFCScriptBlock | null, filePath: string) => {
-  if (!script || !script.setup) {
+  if (!script || script.setup) {
     return
   }
   results.push({ filePath, message: `${BG_WARN}Plain <script> block${BG_RESET} found` })
@@ -27,4 +27,6 @@ const reportPlainScript = () => {
   return offenses
 }
 
-export { checkPlainScript, reportPlainScript }
+const resetPlainScript = () => (results.length = 0)
+
+export { checkPlainScript, reportPlainScript, resetPlainScript }
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index 3e07dd9..79ce381 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -6,12 +6,13 @@ export const RULES = {
     'componentFilenameCasing',
     'componentFiles',
     'directiveShorthands',
+    'fullWordComponentName',
+    'multiAttributeElements',
     'propNameCasing',
     'quotedAttributeValues',
     'selfClosingComponents',
     'simpleComputed',
     'templateSimpleExpression',
-    'fullWordComponentName',
   ],
   'rrd': [
     'cyclomaticComplexity',
diff --git a/src/rules/vue-strong/multiAttributeElements.ts b/src/rules/vue-strong/multiAttributeElements.ts
new file mode 100644
index 0000000..da8ab42
--- /dev/null
+++ b/src/rules/vue-strong/multiAttributeElements.ts
@@ -0,0 +1,53 @@
+import type { SFCTemplateBlock } from '@vue/compiler-sfc'
+import type { FileCheckResult, Offense } from '../../types'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+
+const results: FileCheckResult[] = []
+
+const checkMultiAttributeElements = (template: SFCTemplateBlock | null, filePath: string) => {
+  if (!template)
+    return
+
+  // Regex to match elements with attributes
+  // eslint-disable-next-line regexp/no-super-linear-backtracking
+  const elementRegex = /<(\w+)([^>]*)>/g
+  let match: RegExpExecArray | null
+
+  // eslint-disable-next-line no-cond-assign
+  while ((match = elementRegex.exec(template.content)) !== null) {
+    const elementTag = match[1]
+    const attributesString = match[2]
+
+    // Check if there are multiple attributes
+    const attributes = attributesString.split(/\s+/).filter(attr => attr.trim() !== '')
+
+    if (attributes.length > 1) {
+      // Check if attributes are on separate lines
+      const attributeLines = attributesString.split('\n').length
+      if (attributeLines === 1) {
+        results.push({ filePath, message: `Element ${BG_WARN}<${elementTag}>${BG_RESET} should have its attributes on separate lines` })
+      }
+    }
+  }
+}
+
+const reportMultiAttributeElements = () => {
+  const offenses: Offense[] = []
+
+  if (results.length > 0) {
+    results.forEach((result) => {
+      offenses.push({
+        file: result.filePath,
+        rule: `${TEXT_INFO}vue-strong ~ multi-attribute elements${TEXT_RESET}`,
+        description: `👉 ${TEXT_WARN}Elements with multiple attributes should span multiple lines, with one attribute per line.${TEXT_RESET}`,
+        message: `${result.message} 🚨`,
+      })
+    })
+  }
+
+  return offenses
+}
+
+const resetMultiAttributeElements = () => (results.length = 0)
+
+export { checkMultiAttributeElements, reportMultiAttributeElements, resetMultiAttributeElements }
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index d5dd695..235f12f 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -29,6 +29,7 @@ import { checkDeepIndentation } from './rules/rrd/deepIndentation'
 import { checkElementSelectorsWithScoped } from './rules/vue-caution/elementSelectorsWithScoped'
 import { checkHtmlLink } from './rules/rrd/htmlLink'
 import { checkMagicNumbers } from './rules/rrd/magicNumbers'
+import { checkMultiAttributeElements } from './rules/vue-strong/multiAttributeElements'
 
 export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: Array<RuleSetType>) => {
   const script = descriptor.scriptSetup || descriptor.script
@@ -52,6 +53,7 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: A
     checkQuotedAttributeValues(descriptor, filePath)
     checkDirectiveShorthands(descriptor, filePath)
     checkFullWordComponentName(filePath)
+    checkMultiAttributeElements(descriptor.template, filePath)
   }
 
   if (apply.includes('vue-recommended')) {
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index b370f95..6bcabd7 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -27,6 +27,8 @@ import { reportDeepIndentation } from './rules/rrd/deepIndentation'
 import type { GroupBy, Offense, OffensesGrouped, ReportFunction } from './types'
 import { reportHtmlLink } from './rules/rrd/htmlLink'
 import { reportMagicNumbers } from './rules/rrd/magicNumbers'
+import { reportMultiAttributeElements } from './rules/vue-strong/multiAttributeElements'
+import { reportElementSelectorsWithScoped } from './rules/vue-caution/elementSelectorsWithScoped'
 
 export const reportRules = (groupBy: GroupBy) => {
   let errors = 0
@@ -60,14 +62,15 @@ export const reportRules = (groupBy: GroupBy) => {
 
   // vue-strong rules
   processOffenses(reportComponentFilenameCasing)
-  processOffenses(reportSelfClosingComponents)
+  processOffenses(reportComponentFiles)
+  processOffenses(reportDirectiveShorthands)
+  processOffenses(reportFullWordComponentName)
+  processOffenses(reportMultiAttributeElements)
   processOffenses(reportPropNameCasing)
-  processOffenses(reportTemplateSimpleExpression)
   processOffenses(reportQuotedAttributeValues)
-  processOffenses(reportDirectiveShorthands)
+  processOffenses(reportSelfClosingComponents)
   processOffenses(reportSimpleComputed)
-  processOffenses(reportComponentFiles)
-  processOffenses(reportFullWordComponentName)
+  processOffenses(reportTemplateSimpleExpression)
 
   // vue-recommended rules
   processOffenses(reportTopLevelElementOrder)
@@ -75,6 +78,7 @@ export const reportRules = (groupBy: GroupBy) => {
 
   // vue-caution rules
   processOffenses(reportImplicitParentChildCommunication)
+  processOffenses(reportElementSelectorsWithScoped)
 
   // rrd rules
   processOffenses(reportCyclomaticComplexity)


Test patch:
diff --git a/src/rules/rrd/plainScript.test.ts b/src/rules/rrd/plainScript.test.ts
new file mode 100644
index 0000000..faa7dc0
--- /dev/null
+++ b/src/rules/rrd/plainScript.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SFCScriptBlock } from '@vue/compiler-sfc'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import { checkPlainScript, reportPlainScript, resetPlainScript } from './plainScript'
+
+describe('plainScript', () => {
+  beforeEach(() => {
+    resetPlainScript()
+  })
+
+  it('should not report files with <script setup>', () => {
+    const script = {
+      content: `
+        <script setup>
+          const message = 'Hello, world!';
+        </script>
+      `,
+      setup: true,
+    } as SFCScriptBlock
+    const filename = 'with-script-setup.vue'
+    checkPlainScript(script, filename)
+    expect(reportPlainScript().length).toBe(0)
+    expect(reportPlainScript()).toStrictEqual([])
+  })
+
+  it('should report files with a plain <script> block', () => {
+    const script = {
+      content: `
+        <script>
+          export default {
+            data() {
+              return {
+                message: 'Hello, world!'
+              }
+            }
+          }
+        </script>
+      `,
+      setup: false,
+    } as SFCScriptBlock
+    const filename = 'plain-script.vue'
+    checkPlainScript(script, filename)
+    expect(reportPlainScript().length).toBe(1)
+    expect(reportPlainScript()).toStrictEqual([{
+      file: filename,
+      rule: `${TEXT_INFO}rrd ~ Plain <script> blocks${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN} Consider using <script setup> to leverage the new SFC <script> syntax.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/plain-script.html`,
+      message: `${BG_WARN}Plain <script> block${BG_RESET} found 🚨`,
+    }])
+  })
+})
diff --git a/src/rules/vue-strong/multiAttributeElements.test.ts b/src/rules/vue-strong/multiAttributeElements.test.ts
new file mode 100644
index 0000000..0faa165
--- /dev/null
+++ b/src/rules/vue-strong/multiAttributeElements.test.ts
@@ -0,0 +1,70 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SFCTemplateBlock } from '@vue/compiler-sfc'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import { checkMultiAttributeElements, reportMultiAttributeElements, resetMultiAttributeElements } from './multiAttributeElements'
+
+describe('multiAttributeElements', () => {
+  beforeEach(() => {
+    resetMultiAttributeElements()
+  })
+
+  it('should not report files where elements have single attributes', () => {
+    const template = {
+      content: `
+        <template>
+          <div id="app"></div>
+          <button @click="handleClick">Click me</button>
+        </template>
+      `,
+    } as SFCTemplateBlock
+    const filename = 'single-attribute-element.vue'
+    checkMultiAttributeElements(template, filename)
+    expect(reportMultiAttributeElements().length).toBe(0)
+    expect(reportMultiAttributeElements()).toStrictEqual([])
+  })
+
+  it('should report files where one element has multiple attributes on the same line', () => {
+    const template = {
+      content: `
+        <template>
+          <div id="app" class="container" style="color: red;"></div>
+        </template>
+      `,
+    } as SFCTemplateBlock
+    const filename = 'multi-attribute-element.vue'
+    const element = 'div'
+    checkMultiAttributeElements(template, filename)
+    expect(reportMultiAttributeElements().length).toBe(1)
+    expect(reportMultiAttributeElements()).toStrictEqual([{
+      file: filename,
+      rule: `${TEXT_INFO}vue-strong ~ multi-attribute elements${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Elements with multiple attributes should span multiple lines, with one attribute per line.${TEXT_RESET}`,
+      message: `Element ${BG_WARN}<${element}>${BG_RESET} should have its attributes on separate lines 🚨`,
+    }])
+  })
+
+  it('should report files where multiple elements have multiple attributes on the same line', () => {
+    const template = {
+      content: `
+        <template>
+          <div id="app" class="container" style="color: red;"></div>
+          <button type="button" class="btn" @click="handleClick">Click me</button>
+        </template>
+      `,
+    } as SFCTemplateBlock
+    const filename = 'multiple-multi-attribute-elements.vue'
+    checkMultiAttributeElements(template, filename)
+    expect(reportMultiAttributeElements().length).toBe(2)
+    expect(reportMultiAttributeElements()).toStrictEqual([{
+      file: filename,
+      rule: `${TEXT_INFO}vue-strong ~ multi-attribute elements${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Elements with multiple attributes should span multiple lines, with one attribute per line.${TEXT_RESET}`,
+      message: `Element ${BG_WARN}<div>${BG_RESET} should have its attributes on separate lines 🚨`,
+    }, {
+      file: filename,
+      rule: `${TEXT_INFO}vue-strong ~ multi-attribute elements${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Elements with multiple attributes should span multiple lines, with one attribute per line.${TEXT_RESET}`,
+      message: `Element ${BG_WARN}<button>${BG_RESET} should have its attributes on separate lines 🚨`,
+    }])
+  })
+})