Repository: rrd108/vue-mess-detector
Pull request #78 (2024-07-30 16:38:26)

feat: add vue-recommended top-level-element-order rule w tests
This PR includes:
- new vue-recommended rule with tests
- fix typo in `src/rules/rules.ts`
- add vue-recommended to `rulesCheck.ts`


Patch:
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index 3972837..3f894e7 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -1,7 +1,7 @@
 export const RULES = {
   'vue-caution': ['implicitParentChildCommunication'],
   'vue-essential': ['globalStyle', 'simpleProp', 'singleNameComponent', 'vforNoKey', 'vifWithVfor'],
-  'vue-reccomended': [],
+  'vue-recommended': ['topLevelElementOrder'],
   'vue-strong': [
     'componentFilenameCasing',
     'componentFiles',
diff --git a/src/rules/vue-recommended/topLevelElementOrder.ts b/src/rules/vue-recommended/topLevelElementOrder.ts
new file mode 100644
index 0000000..e1e6ef8
--- /dev/null
+++ b/src/rules/vue-recommended/topLevelElementOrder.ts
@@ -0,0 +1,48 @@
+import { BG_RESET, BG_WARN, TEXT_WARN, TEXT_RESET, TEXT_INFO } from '../asceeCodes'
+
+type TopLevelElementOrder = { filename: string }
+
+const topLevelElementOrderFiles: TopLevelElementOrder[] = []
+
+/* The opinionated correct order is: script, template, style */
+const checkTopLevelElementOrder = (source: string, filePath: string) => {
+  // Apply `toString()` because it throws `indexOf` error otherwise
+  const content = source.toString();
+
+  const scriptIdx = content.indexOf('<script setup>');
+  const templateIdx = content.indexOf('<template>');
+  const styleIdx = content.indexOf('<style>');
+
+  // Create an array of present elements and their indices
+  const elements = [
+    { name: 'script', index: scriptIdx },
+    { name: 'template', index: templateIdx },
+    { name: 'style', index: styleIdx }
+  ].filter(el => el.index !== -1);
+
+  // Check if the order is correct
+  const isCorrectOrder = elements.every((el, idx) => {
+    if (idx === 0) return true;
+    return elements[idx - 1].index < el.index
+  });
+
+  if (isCorrectOrder) return; // If it's correct, do nothing
+
+  topLevelElementOrderFiles.push({ filename: filePath });
+}
+
+const reportTopLevelElementOrder = () => {
+  if (topLevelElementOrderFiles.length > 0) {
+    console.log(`\n${TEXT_INFO}vue-recommended${TEXT_RESET} ${BG_WARN}SFC top-level element order${BG_RESET} detected in ${topLevelElementOrderFiles.length} files.`)
+    console.log(
+      `👉 ${TEXT_WARN}Single-File Components should always order <script>, <template>, and <style> tags consistently.${TEXT_RESET} See: https://vuejs.org/style-guide/rules-recommended.html#single-file-component-top-level-element-order`
+    )
+    topLevelElementOrderFiles.forEach(file => {
+      console.log(` - ${file.filename} 🚨`)
+    })
+  }
+  return topLevelElementOrderFiles.length;
+}
+
+export { checkTopLevelElementOrder, reportTopLevelElementOrder }
+
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index 0a4cc2f..2cb57e2 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -14,6 +14,7 @@ import { checkQuotedAttributeValues } from './rules/vue-strong/quotedAttribueVal
 import { checkSelfClosingComponents } from './rules/vue-strong/selfClosingComponents'
 import { checkDirectiveShorthands } from './rules/vue-strong/directiveShorthands'
 import { checkFullWordComponentName } from './rules/vue-strong/fullWordComponentName'
+import { checkTopLevelElementOrder } from './rules/vue-recommended/topLevelElementOrder'
 import { checkTooManyProps } from './rules/rrd/tooManyProps'
 import { checkFunctionSize } from './rules/rrd/functionSize'
 import { checkParameterCount } from './rules/rrd/parameterCount'
@@ -48,6 +49,10 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, ignore:
     checkFullWordComponentName(filePath)
   }
 
+  if (!ignore.includes('vue-recommended')) {
+    checkTopLevelElementOrder(descriptor.source, filePath)
+  }
+
   if (!ignore.includes('vue-caution')) {
     checkImplicitParentChildCommunication(script, filePath)
   }
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index 427c2f3..3de13b7 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -14,6 +14,7 @@ import { reportQuotedAttributeValues } from './rules/vue-strong/quotedAttribueVa
 import { reportSelfClosingComponents } from './rules/vue-strong/selfClosingComponents'
 import { reportDirectiveShorthands } from './rules/vue-strong/directiveShorthands'
 import { reportFullWordComponentName } from './rules/vue-strong/fullWordComponentName'
+import { reportTopLevelElementOrder } from './rules/vue-recommended/topLevelElementOrder'
 import { reportTooManyProps } from './rules/rrd/tooManyProps'
 import { reportFunctionSize } from './rules/rrd/functionSize'
 import { reportParameterCount } from './rules/rrd/parameterCount'
@@ -44,6 +45,9 @@ export const reportRules = () => {
   errors += reportComponentFiles()
   errors += reportFullWordComponentName()
 
+  // vue-recommended rules
+  errors += reportTopLevelElementOrder()
+
   // vue-caution rules
   errors += reportImplicitParentChildCommunication()
 


Test patch:
diff --git a/src/rules/vue-recommended/topLevelElementOrder.test.ts b/src/rules/vue-recommended/topLevelElementOrder.test.ts
new file mode 100644
index 0000000..3c39921
--- /dev/null
+++ b/src/rules/vue-recommended/topLevelElementOrder.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest'
+import { SFCBlock } from '@vue/compiler-sfc'
+import { checkTopLevelElementOrder, reportTopLevelElementOrder } from './topLevelElementOrder'
+
+const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+describe('checkTopLevelElementOrder', () => {
+  it('should not report files where top-level element order is correct', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { ref } from 'vue';
+        let age = ref(22);
+        </script>
+        <template>
+          <h1 class="age-input">{{ age }}</h1>
+        </template>
+        <style>
+        .age-input {
+          color: white;
+        }
+        </style>
+      `
+    } as SFCBlock;
+    const filename = 'sfc-correct-order.vue'
+    checkTopLevelElementOrder(script.content, filename)
+    expect(reportTopLevelElementOrder()).toBe(0)
+    expect(mockConsoleLog).not.toHaveBeenCalled()
+  })
+
+  it('should report files where top-level element order is incorrect', () => {
+    const script = {
+      content: `
+        <template>
+          <h1 class="age-input">{{ age }}</h1>
+        </template>
+        <script setup>
+        import { ref } from 'vue';
+        let age = ref(22);
+        </script>
+        <style>
+        .age-input {
+          color: white;
+        }
+        </style>
+      `
+    } as SFCBlock;
+    const filename = 'sfc-incorrect-order.vue'
+    checkTopLevelElementOrder(script.content, filename)
+    expect(reportTopLevelElementOrder()).toBe(1)
+    expect(mockConsoleLog).toHaveBeenCalled()
+    expect(mockConsoleLog).toHaveBeenLastCalledWith(` - ${filename} 🚨`)
+  })
+
+  it('should report files where one top-level element order is missing', () => {
+    const script = {
+      content: `
+        <style>
+        .age-input {
+          color: white;
+        }
+        </style>
+        <template>
+          <h1 class="age-input">22</h1>
+        </template>
+      `
+    } as SFCBlock;
+    const filename = 'sfc-missing-element.vue'
+    checkTopLevelElementOrder(script.content, filename)
+    expect(reportTopLevelElementOrder()).toBe(2)
+    expect(mockConsoleLog).toHaveBeenCalled()
+    expect(mockConsoleLog).toHaveBeenLastCalledWith(` - ${filename} 🚨`)
+  })
+})
\ No newline at end of file