diff --git a/modules/lint/README.md b/modules/lint/README.md index 461982d..01c2e76 100644 --- a/modules/lint/README.md +++ b/modules/lint/README.md @@ -45,6 +45,11 @@ The preset composes these layers and gates each on what your project actually us | **Tailwind** | Class order and correctness | [`better-tailwindcss`](https://github.com/schoero/eslint-plugin-better-tailwindcss) | | **Vue SFC** | Conventions from `eslint-plugin-vue` (e.g. `lang="ts"` blocks) | `eslint-plugin-vue` | +Nuxt ecosystem integrations can also be selected manually. For example, +`nuxtEcosystem: { nuxtUi: { preset: 'minimal' } }` keeps only the Nuxt UI rules that report broken +or deprecated code; `nuxtUi: false` disables the integration even when `@nuxt/ui` is installed. The +default `recommended` preset adds design-system preferences on top, as warnings. See [Configuration](docs/configuration.md) for the complete options. + **Want a rule added, or a plugin integrated?** [Open an issue](https://github.com/Zerya-Dev/nustack/issues) as the ruleset is meant to grow with what the community uses. ## 🚀 Quick Start diff --git a/modules/lint/docs/configuration.md b/modules/lint/docs/configuration.md index b1ebb5e..05739af 100644 --- a/modules/lint/docs/configuration.md +++ b/modules/lint/docs/configuration.md @@ -124,10 +124,45 @@ but you can force or disable any of them: | `vue` | SFC conventions (`vue/block-lang` → `lang="ts"`) | `.vue` files | | `vueUse` | VueUse/browser API conventions | Nuxt app/client files, or `vue-app` | | `vite` | Vite asset/env safety | Nuxt app/client files, or `vue-app` | -| `nuxtEcosystem` | per-module Nuxt-ecosystem preferences (Nuxt UI today) | each module's own detection (`@nuxt/ui` etc.), never blanket-disabled by a target | +| `nuxtEcosystem` | per-module Nuxt-ecosystem preferences | each module's own detection (`@nuxt/ui` etc.), never blanket-disabled by a target | | `tailwind` | class sorting/correctness via better-tailwindcss (incl. the `:ui` prop) | a Tailwind entry point | | `markdown` | Markdown/MDC linting via mdclint for `content/**/*.md` | always; MDC preset when `@nuxt/content`, `@comark/nuxt`, or `@nuxtjs/mdc` is detected | +### Nuxt ecosystem integrations + +`@nustackjs/lint` detects installed modules and enables their integrations automatically. You can +also enable, configure, or disable each integration explicitly. Every integration provides two presets: + +| Preset | Contents | Severity | +| ------------- | ------------------------------------------------------------- | ---------------- | +| `minimal` | Invalid usage, accessibility defects, removed or renamed APIs | `error` | +| `recommended` | `minimal` plus design-system preferences (`prefer-*`) | `error` / `warn` | + +`recommended` is the default. + +The Nuxt UI rules reuse the SFC parsing the Vue layer already sets up — the plugin ships no +parsers of its own. + +```ts +nustack({ + nuxtEcosystem: { + // Force @nuxt/ui rules on even when package detection is unavailable. + nuxtUi: { + preset: 'minimal', + rules: { '@nustack/nuxt-ui/require-u-app': 'off' }, + }, + }, +}) +``` + +Use `nuxtUi: true` to force the default preset on, or `nuxtUi: false` to disable the detected +integration. The configured Nuxt UI component prefix is read from `nuxt.config.ts` automatically. + +The `prefer-*` rules have no attribute-level opt-out. Suppress a deliberate exception with +`` in the template — this works +because the composed config carries `eslint-plugin-vue`'s `vue/comment-directive` and `.vue` +processor. + ## Context detection outside Nuxt Non-Nuxt targets (`vue-app`, and `nuxt-module`'s `src/**`) have no `nuxt prepare` to diff --git a/modules/lint/package.json b/modules/lint/package.json index 9e59201..f5d646c 100644 --- a/modules/lint/package.json +++ b/modules/lint/package.json @@ -52,6 +52,7 @@ ], "scripts": { "prepack": "nuxt-module-build build", + "prepare": "pnpm --filter '@nustackjs/lint-plugin-*' --if-present build && nuxt-module-build prepare && nuxt-module-build build", "build": "nuxt-module-build prepare && nuxt-module-build build", "dev": "npm run dev:prepare && nuxt dev playground", "dev:build": "nuxt build playground", diff --git a/modules/lint/playground/app/app.vue b/modules/lint/playground/app/app.vue index 9daa00d..03f8faa 100644 --- a/modules/lint/playground/app/app.vue +++ b/modules/lint/playground/app/app.vue @@ -1,5 +1,9 @@ ``` @@ -17,6 +21,7 @@ Nuxt UI v4 (both convert empty/blank values to `null`; v4 also adds `.optional` ```vue ``` @@ -25,6 +30,11 @@ Nuxt UI v4 (both convert empty/blank values to `null`; v4 also adds `.optional` ```js '@nustack/nuxt-ui/no-deprecated-model-modifiers': ['warn', { modifiers: { coerce: 'transform' }, // extra old → new renames, merged onto the built-ins - components: ['uinput', 'utextarea'], // lowercased components to check (replaces the default list) + components: ['Input', 'Textarea'], // components to check (replaces the default list) }] ``` + +## Further reading + +- [Migrating to Nuxt UI v4](https://ui.nuxt.com/docs/getting-started/migration/v4) +- [Nuxt UI Input](https://ui.nuxt.com/docs/components/input) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.test.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.test.ts index 0755465..4ee8ddf 100644 --- a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.test.ts +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.test.ts @@ -1,27 +1,25 @@ -import { RuleTester } from 'eslint' import { describe, it } from 'vitest' -import vueParser from 'vue-eslint-parser' -import plugin from '../../../index.js' +import { ruleTester as tester } from '../../../../tests/rule-tester.js' +import { nuxtUiPlugin } from '../../../index.js' -const rule = plugin.rules?.['no-deprecated-model-modifiers'] +const rule = nuxtUiPlugin.rules['no-deprecated-model-modifiers'] describe('no-deprecated-model-modifiers', () => { it('reports the renamed v-model.nullify modifier', () => { - const tester = new RuleTester({ languageOptions: { ecmaVersion: 'latest', sourceType: 'module', parser: vueParser } }) - - tester.run('no-deprecated-model-modifiers', rule as never, { + tester.run('no-deprecated-model-modifiers', rule, { valid: [ - { filename: 'component.vue', code: '' }, - { filename: 'component.vue', code: '' }, - { filename: 'component.vue', code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, ], invalid: [ - { filename: 'component.vue', code: '', errors: [{ messageId: 'preferNullable' }] }, - { filename: 'component.vue', code: '', errors: [{ messageId: 'preferNullable' }] }, - { filename: 'component.vue', code: '', errors: [{ messageId: 'preferNullable' }] }, - // Extended via options: extra modifier rename and a custom target component. - { filename: 'component.vue', code: '', options: [{ modifiers: { coerce: 'transform' } }], errors: [{ messageId: 'preferNullable' }] }, - { filename: 'component.vue', code: '', options: [{ components: ['ucustom'] }], errors: [{ messageId: 'preferNullable' }] }, + { code: '', output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', options: [{ modifiers: { coerce: 'transform' } }], output: '', errors: [{ messageId: 'preferNullable' }] }, + { code: '', options: [{ components: ['Custom'] }], output: '', errors: [{ messageId: 'preferNullable' }] }, ], }) }) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.ts index 120bac9..bb41426 100644 --- a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.ts +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-deprecated-model-modifiers/index.ts @@ -1,13 +1,20 @@ -import type { Rule } from '@oxlint/plugins' +import type { Context, Rule, Visitor } from '@oxlint/plugins' +import type { AST as VueAST } from 'vue-eslint-parser' import { docsUrl } from '../../../utils/docs-url.js' -import { defineTemplateVisitor } from '../utils.js' +import { defineTemplateVisitor, normalizePropName } from '../../../utils/template.js' +import { createNuxtUiMatcher } from '../components.js' -/** Verified 2026-06-30 against ui.nuxt.com (v4); re-verify on the next Nuxt UI major. */ +/** Verified 2026-07-21 against the ui.nuxt.com v4 migration guide; re-verify on the next major. */ const DEPRECATED_MODIFIERS: Record = { nullify: 'nullable', } -const TARGET_COMPONENTS = ['uinput', 'uinputnumber', 'utextarea'] +const TARGET_COMPONENTS = ['Input', 'InputNumber', 'Textarea'] + +interface Options { + modifiers?: Record + components?: string[] +} function vModel(node: any): any { return node.startTag.attributes.find( @@ -15,6 +22,18 @@ function vModel(node: any): any { ) ?? null } +function modelModifiersObject(node: any): any | null { + const attribute = node.startTag.attributes.find((candidate: any) => { + if (!candidate.directive || candidate.key.name?.name !== 'bind') + return false + const argument = candidate.key.argument + return argument?.type === 'VIdentifier' && normalizePropName(argument.name) === normalizePropName('model-modifiers') + }) + return attribute?.value?.expression?.type === 'ObjectExpression' + ? attribute.value.expression + : null +} + export const noDeprecatedModelModifiers: Rule = { meta: { type: 'problem', @@ -22,12 +41,11 @@ export const noDeprecatedModelModifiers: Rule = { description: 'Disallow the `v-model.nullify` modifier renamed to `.nullable` in Nuxt UI v4.', url: docsUrl('nuxt-ui/no-deprecated-model-modifiers'), }, + fixable: 'code', schema: [{ type: 'object', properties: { - /** Extra `old` → `new` modifier renames, merged onto the built-in table. */ modifiers: { type: 'object', additionalProperties: { type: 'string' } }, - /** Components to check, lowercased. Replaces the built-in list when provided. */ components: { type: 'array', items: { type: 'string' } }, }, additionalProperties: false, @@ -36,14 +54,15 @@ export const noDeprecatedModelModifiers: Rule = { preferNullable: 'The `v-model.{{ old }}` modifier was renamed in Nuxt UI v4, use `v-model.{{ replacement }}` instead.', }, }, - create(context: any) { - const options = context.options[0] ?? {} + create(context: Context): Visitor { + const options = (context.options[0] ?? {}) as Options const modifiers: Record = { ...DEPRECATED_MODIFIERS, ...options.modifiers } - const targets = new Set(options.components ?? TARGET_COMPONENTS) + const matcher = createNuxtUiMatcher(context) + const targets: string[] = options.components ?? TARGET_COMPONENTS return defineTemplateVisitor(context, { - VElement(node: any) { - if (!targets.has(node.name)) + VElement(node: VueAST.VElement) { + if (!matcher.isOneOf(node, targets)) return const model = vModel(node) @@ -54,6 +73,27 @@ export const noDeprecatedModelModifiers: Rule = { loc: modifier.loc ?? model.key.loc, messageId: 'preferNullable', data: { old: modifier.name, replacement }, + fix: (fixer: any) => fixer.replaceTextRange(modifier.range, replacement), + }) + } + } + + for (const property of modelModifiersObject(node)?.properties ?? []) { + if (property.type !== 'Property' || property.computed) + continue + const name = property.key.name ?? property.key.value + const replacement = modifiers[name] + if (replacement) { + context.report({ + loc: property.key.loc ?? property.loc, + messageId: 'preferNullable', + data: { old: name, replacement }, + fix: (fixer: any) => fixer.replaceTextRange( + property.key.range, + property.shorthand + ? `${replacement}: ${name}` + : property.key.type === 'Literal' ? `'${replacement}'` : replacement, + ), }) } } diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.md b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.md new file mode 100644 index 0000000..011bffb --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.md @@ -0,0 +1,38 @@ +# `@nustack/nuxt-ui/no-invalid-prop-combinations` + +Disallow static prop combinations that Nuxt UI documents as incompatible. + +Currently covered: + +- `UFileUpload variant="button"` cannot be combined with `multiple`. +- `UFileUpload layout` requires `variant="area"`. +- `UFileUpload position` requires `variant="area"` and `layout="list"`. +- `UAccordion collapsible` is only meaningful for the default `type="single"`. + +Dynamic values are ignored when the relationship cannot be proven at lint time. An +exceptional suppression uses a standard ESLint disable comment. + +## Incorrect + +```vue + +``` + +## Correct + +```vue + +``` + +## Further reading + +- [Nuxt UI FileUpload](https://ui.nuxt.com/docs/components/file-upload) +- [Nuxt UI Accordion](https://ui.nuxt.com/docs/components/accordion) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.test.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.test.ts new file mode 100644 index 0000000..15d942e --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'vitest' +import { ruleTester as tester } from '../../../../tests/rule-tester.js' +import { nuxtUiPlugin } from '../../../index.js' + +describe('no-invalid-prop-combinations', () => { + it('checks documented component constraints', () => { + tester.run('no-invalid-prop-combinations', nuxtUiPlugin.rules['no-invalid-prop-combinations'], { + valid: [ + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + ], + invalid: [ + { code: '', errors: [{ messageId: 'fileButtonMultiple' }] }, + { code: '', errors: [{ messageId: 'fileLayoutArea' }] }, + { code: '', errors: [{ messageId: 'filePositionList' }] }, + { code: '', errors: [{ messageId: 'accordionCollapsible' }] }, + ], + }) + }) +}) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.ts new file mode 100644 index 0000000..ac68c57 --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/no-invalid-prop-combinations/index.ts @@ -0,0 +1,57 @@ +import type { Context, Rule, Visitor } from '@oxlint/plugins' +import type { AST as VueAST } from 'vue-eslint-parser' +import { docsUrl } from '../../../utils/docs-url.js' +import { defineTemplateVisitor, getAttributeValue, getStaticAttribute, getStaticBoolean } from '../../../utils/template.js' +import { createNuxtUiMatcher } from '../components.js' + +export const noInvalidPropCombinations: Rule = { + meta: { + type: 'problem', + docs: { + description: 'Disallow documented Nuxt UI prop combinations that cannot work together.', + url: docsUrl('nuxt-ui/no-invalid-prop-combinations'), + }, + schema: [], + messages: { + fileButtonMultiple: '`UFileUpload variant="button"` only supports a single file; remove `multiple` or use `variant="area"`.', + fileLayoutArea: '`UFileUpload layout` is only supported with `variant="area"`.', + filePositionList: '`UFileUpload position` requires `variant="area"` with `layout="list"`.', + accordionCollapsible: '`UAccordion collapsible` is only meaningful with the default `type="single"`.', + }, + }, + create(context: Context): Visitor { + const matcher = createNuxtUiMatcher(context) + + return defineTemplateVisitor(context, { + VElement(node: VueAST.VElement) { + if (matcher.is(node, 'FileUpload')) { + const variant = getAttributeValue(node, 'variant') + const multiple = getStaticBoolean(node, 'multiple') + const layout = getAttributeValue(node, 'layout') + const position = getAttributeValue(node, 'position') + + if (variant.known && variant.value === 'button' && multiple === true) { + context.report({ loc: node.startTag.loc, messageId: 'fileButtonMultiple' }) + } + + if (layout.known && layout.definite && variant.known && variant.value === 'button') { + context.report({ loc: node.startTag.loc, messageId: 'fileLayoutArea' }) + } + + if (position.definite && position.known && variant.known && layout.known) { + const effectiveVariant = variant.definite ? variant.value : 'area' + const effectiveLayout = layout.definite ? layout.value : 'grid' + if (effectiveVariant !== 'area' || effectiveLayout !== 'list') + context.report({ loc: node.startTag.loc, messageId: 'filePositionList' }) + } + } + + if (matcher.is(node, 'Accordion')) { + const type = getStaticAttribute(node, 'type') + if (type === 'multiple' && getStaticBoolean(node, 'collapsible') === true) + context.report({ loc: node.startTag.loc, messageId: 'accordionCollapsible' }) + } + }, + }) + }, +} diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.md b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.md new file mode 100644 index 0000000..3756905 --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.md @@ -0,0 +1,19 @@ +# `@nustack/nuxt-ui/prefer-link-to` + +Prefer `to` on `ULink` and link-capable `UButton` components for a consistent routing-aware +API. Nuxt UI also supports `href`, so this rule is a preference rather than a defect and is +reported as a warning. Suppress an intentional `href` with +``. + +```vue + +Settings + + +Settings +``` + +## Further reading + +- [Nuxt UI Link](https://ui.nuxt.com/docs/components/link) +- [Nuxt UI Button](https://ui.nuxt.com/docs/components/button) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.test.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.test.ts new file mode 100644 index 0000000..27995f6 --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from 'vitest' +import { ruleTester as tester } from '../../../../tests/rule-tester.js' +import { nuxtUiPlugin } from '../../../index.js' + +describe('prefer-link-to', () => { + it('prefers to on link-capable components', () => { + tester.run('prefer-link-to', nuxtUiPlugin.rules['prefer-link-to'], { + valid: [{ code: '' }], + invalid: [{ code: '', errors: [{ messageId: 'preferTo' }] }], + }) + }) +}) diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.ts b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.ts new file mode 100644 index 0000000..eba2792 --- /dev/null +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-link-to/index.ts @@ -0,0 +1,32 @@ +import type { Context, Rule, Visitor } from '@oxlint/plugins' +import type { AST as VueAST } from 'vue-eslint-parser' +import { docsUrl } from '../../../utils/docs-url.js' +import { defineTemplateVisitor, hasAttribute } from '../../../utils/template.js' +import { createNuxtUiMatcher } from '../components.js' + +const LINK_COMPONENTS = new Set(['Link', 'Button']) + +export const preferLinkTo: Rule = { + meta: { + type: 'suggestion', + docs: { + description: 'Prefer Nuxt UI/NuxtLink `to` on link-capable components over `href`.', + url: docsUrl('nuxt-ui/prefer-link-to'), + }, + schema: [], + messages: { + preferTo: 'Use `to` instead of `href` on `<{{ component }}>` so Nuxt routing and link props stay consistent.', + }, + }, + create(context: Context): Visitor { + const matcher = createNuxtUiMatcher(context) + + return defineTemplateVisitor(context, { + VElement(node: VueAST.VElement) { + const component = matcher.matchOf(node, LINK_COMPONENTS) + if (component && hasAttribute(node, 'href') && !hasAttribute(node, 'to')) + context.report({ loc: node.startTag.loc, messageId: 'preferTo', data: { component: matcher.prefixed(component) } }) + }, + }) + }, +} diff --git a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-u-button/index.md b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-u-button/index.md index 7244aa8..7de1d26 100644 --- a/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-u-button/index.md +++ b/packages/lint-plugin-nuxt-ecosystem/src/rules/nuxt-ui/prefer-u-button/index.md @@ -18,4 +18,12 @@ Prefer Nuxt UI's `` over raw `