Skip to content

Commit cf92dc3

Browse files
authored
Merge pull request #13 from andrewmolyuk/components-index-required
feat: add sfc-required rule to enforce Single File Component structure for Vue components
2 parents adfd20d + dd8cc0c commit cf92dc3

File tree

11 files changed

+132
-17
lines changed

11 files changed

+132
-17
lines changed

.github/workflows/test-pr.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ jobs:
2929
- run: make install
3030

3131
- run: make build
32+
33+
- uses: codacy/codacy-coverage-reporter-action@v1.3.0
34+
with:
35+
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
36+
coverage-reports: coverage/lcov.info

bun.lock

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"minimatch": "^10.0.3",
1111
"typescript": "^5.9.2",
1212
"typescript-eslint": "^8.43.0",
13+
"vue-eslint-parser": "^10.2.0",
1314
},
1415
"devDependencies": {
1516
"@commitlint/cli": "^19.8.1",
@@ -22,7 +23,7 @@
2223
"husky": "^9.1.7",
2324
"markdownlint": "^0.38.0",
2425
"markdownlint-cli": "^0.45.0",
25-
"npm-check-updates": "^18.1.0",
26+
"npm-check-updates": "^18.1.1",
2627
"prettier": "^3.6.2",
2728
"semantic-release": "^24.2.8",
2829
"vitest": "^3.2.4",
@@ -844,7 +845,7 @@
844845

845846
"npm": ["npm@10.9.3", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.1", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.2", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.4.1", "ci-info": "^4.2.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.1", "libnpmexec": "^9.0.1", "libnpmfund": "^6.0.1", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.1", "libnpmpublish": "^10.0.1", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.5", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.2.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.0", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.1", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.3", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.1", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-6Eh1u5Q+kIVXeA8e7l2c/HpnFFcwrkt37xDMujD5be1gloWa9p6j3Fsv3mByXXmqJHy+2cElRMML8opNT7xIJQ=="],
846847

847-
"npm-check-updates": ["npm-check-updates@18.1.0", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-1TQ6fO4HxVW4K/TWUPOa1KRbaL0Y9+CgDJeTkrA3c4YFgaW8uoxllCKlm4OM/28C9E9NR3MlkxtcFs0Z1VaCPg=="],
848+
"npm-check-updates": ["npm-check-updates@18.1.1", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-sr+z5tEZop9n+uxAv/FVbpIdrayfG3Dr/D91igb+GyBl9eiudYUfGUZEBsmHq6kMOGEssSM3YWrP3njQjVU4Gw=="],
848849

849850
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
850851

@@ -1096,6 +1097,8 @@
10961097

10971098
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
10981099

1100+
"vue-eslint-parser": ["vue-eslint-parser@10.2.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="],
1101+
10991102
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
11001103

11011104
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import prettierPluginRecommended from 'eslint-plugin-prettier/recommended'
44
import tseslint from 'typescript-eslint'
55

66
export default [
7-
{ ignores: ['\\!JS/**', 'node_modules/**', 'dist/**', '.git/**', '**/*.d.ts'] },
7+
{ ignores: ['node_modules/**', 'dist/**', '.git/**', '**/*.d.ts'] },
88
// Base recommended configuration
99
eslint.configs.recommended,
1010

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"husky": "^9.1.7",
4545
"markdownlint-cli": "^0.45.0",
4646
"markdownlint": "^0.38.0",
47-
"npm-check-updates": "^18.1.0",
47+
"npm-check-updates": "^18.1.1",
4848
"prettier": "^3.6.2",
4949
"semantic-release": "^24.2.8",
5050
"vitest": "^3.2.4"
@@ -54,7 +54,8 @@
5454
"@vue/compiler-sfc": "^3.5.21",
5555
"eslint": "^9.35.0",
5656
"minimatch": "^10.0.3",
57+
"typescript": "^5.9.2",
5758
"typescript-eslint": "^8.43.0",
58-
"typescript": "^5.9.2"
59+
"vue-eslint-parser": "^10.2.0"
5960
}
6061
}

src/rules.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1+
import { componentsIndexRequired } from './rules/components-index-required'
2+
import { featureIndexRequired } from './rules/feature-index-required'
3+
import { fileComponentNaming } from './rules/file-component-naming'
14
import { fileTsNaming } from './rules/file-ts-naming'
5+
import { folderKebabCase } from './rules/folder-kebab-case'
6+
import { sfcRequired } from './rules/sfc-required'
7+
import { sharedIndexRequired } from './rules/shared-ui-index-required'
28
import { VueModularRuleModule } from './types'
39

410
export const rules: Record<string, VueModularRuleModule> = {
11+
'components-index-required': componentsIndexRequired,
12+
'feature-index-required': featureIndexRequired,
13+
'file-component-naming': fileComponentNaming,
514
'file-ts-naming': fileTsNaming,
15+
'folder-kebab-case': folderKebabCase,
16+
'shared-ui-index-required': sharedIndexRequired,
17+
'sfc-required': sfcRequired,
618
}

src/rules/sfc-required.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createRule, parseRuleOptions, isIgnored, isComponent, parseProjectOptions } from '../utils'
2+
import type { VueModularRuleModule, VueModularRuleContext } from '../types'
3+
import { parse } from '@vue/compiler-sfc'
4+
5+
const defaultOptions = {
6+
ignores: [''],
7+
}
8+
9+
//
10+
export const sfcRequired = createRule<VueModularRuleModule>({
11+
create(context: VueModularRuleContext) {
12+
const options = parseRuleOptions(context, defaultOptions)
13+
if (isIgnored(context.filename, options.ignores)) return {}
14+
15+
const projectOptions = parseProjectOptions(context)
16+
17+
// Only check Component files
18+
if (!isComponent(context.filename, projectOptions.rootPath, projectOptions.rootAlias, projectOptions.componentsFolderName)) return {}
19+
20+
return {
21+
Program(node) {
22+
const { descriptor } = parse(context.sourceCode.text)
23+
const hasTemplate = !!descriptor.template
24+
const hasScript = !!descriptor.script || !!descriptor.scriptSetup
25+
26+
if (!hasTemplate && !hasScript) {
27+
context.report({
28+
node,
29+
messageId: 'missingSfcBlock',
30+
data: { name: context.filename },
31+
})
32+
}
33+
},
34+
}
35+
},
36+
name: 'sfc-required',
37+
recommended: true,
38+
level: 'warn',
39+
meta: {
40+
type: 'suggestion',
41+
docs: {
42+
category: 'Component Rules',
43+
description: 'Require Single File Component structure for Vue components',
44+
},
45+
schema: [
46+
{
47+
type: 'object',
48+
properties: {
49+
ignores: { type: 'array', items: { type: 'string' } },
50+
},
51+
additionalProperties: false,
52+
},
53+
],
54+
defaultOptions: [defaultOptions],
55+
messages: {
56+
missingSfcBlock: 'Vue file "{{name}}" should contain at least a <template> or a <script> block.',
57+
},
58+
},
59+
})

tests/rules/file-component-naming.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it } from 'vitest'
22
import { fileComponentNaming } from '../../src/rules/file-component-naming'
3-
import { RuleTester } from 'eslint'
3+
import { getRuleTester } from '../test-utils'
44

5-
const ruleTester = new RuleTester()
5+
const ruleTester = getRuleTester()
66

77
describe('file-component-naming', () => {
88
it('should pass for PascalCase .vue filename', () => {

tests/rules/sfc-required.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { getRuleTester } from '../test-utils'
2+
import { sfcRequired } from '../../src/rules/sfc-required'
3+
import { describe, it } from 'vitest'
4+
5+
const ruleTester = getRuleTester()
6+
7+
describe('sfc-required', () => {
8+
it('test cases for sfc-required rule', () => {
9+
ruleTester.run('sfc-required', sfcRequired, {
10+
valid: [
11+
{ code: '<div></div>', filename: 'app/components/MyComp.vue' },
12+
{ code: '', filename: 'src/components/MyComp.vue', options: [{ ignores: ['**/MyComp.vue'] }] },
13+
{ code: '<template><div/></template>', filename: 'src/components/MyComp.vue' },
14+
{ code: '<script>export default {}</script>', filename: 'src/components/MyComp.vue' },
15+
{ code: '<script setup>export default {}</script>', filename: 'src/components/MyComp.vue' },
16+
{ code: '<template><div/></template><style></style><script></script>', filename: 'src/components/MyComp.vue' },
17+
],
18+
invalid: [
19+
{
20+
code: '<div></div>',
21+
filename: 'src/components/MyComp.vue',
22+
errors: [{ messageId: 'missingSfcBlock', data: { name: 'src/components/MyComp.vue' } }],
23+
},
24+
{
25+
code: '<style></style>',
26+
filename: 'src/components/MyComp.vue',
27+
errors: [{ messageId: 'missingSfcBlock', data: { name: 'src/components/MyComp.vue' } }],
28+
},
29+
],
30+
})
31+
})
32+
})

tests/test-utils/getRuleTester.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import parser from 'vue-eslint-parser'
2+
import { RuleTester } from 'eslint'
3+
4+
// Utility to create a RuleTester instance with Vue parser and default options
5+
export const getRuleTester = () => {
6+
return new RuleTester({
7+
languageOptions: {
8+
parser,
9+
},
10+
})
11+
}

tests/test-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './mockFile'
22
export * from './setupTest'
33
export * from './runRule'
4+
export * from './getRuleTester.ts'

0 commit comments

Comments
 (0)