Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion modules/lint/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<!-- eslint-disable-next-line @nustack/nuxt-ui/prefer-u-button -->` 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
Expand Down
1 change: 1 addition & 0 deletions modules/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions modules/lint/playground/app/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script setup lang="ts">
const count = ref(0)

function increment() {
count.value++
}
</script>

<template>
Expand All @@ -8,12 +12,12 @@ const count = ref(0)
<UButton
class="px-4 py-2 bg-primary flex"
:ui="{ base: 'rounded-lg text-white px-2' }"
@click="() => count++"
@click="increment"
>
Clicked {{ count }}
</UButton>

<!-- raw <button> → nustack/prefer-u-button warns -->
<!-- raw <button> → @nustack/nuxt-ui/prefer-u-button warns -->
<button class="p-2">
Raw button
</button>
Expand Down
2 changes: 1 addition & 1 deletion modules/lint/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint": "nuxt prepare && eslint ."
},
"dependencies": {
"@nustackjs/lint": "latest",
"@nustackjs/lint": "workspace:*",
"@nuxt/content": "^3.15.0",
"@nuxt/eslint": "^1.16.0",
"@nuxt/ui": "^4.9.0",
Expand Down
1 change: 1 addition & 0 deletions modules/lint/src/addon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function setupNustackContext(nuxt: Nuxt): void {
async function buildContext(): Promise<NustackContext> {
return {
modules: detectModules(),
nuxtUi: { prefix: (nuxt.options as any).ui?.prefix ?? 'U' },
tailwind: await detectTailwind(),
autoImports: await collectAutoImports(),
components,
Expand Down
6 changes: 2 additions & 4 deletions modules/lint/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export interface NustackLintOptions {
/** Vite build/runtime conventions. */
vite?: ConcernToggle<ViteConcernOptions>
/**
* Nuxt-ecosystem rules (Nuxt UI today; Pinia/Content later). `false` disables the whole
* ecosystem; an object tunes each module, e.g. `{ nuxtUi: false }`. Each module
* Nuxt-ecosystem rules (Nuxt UI and Nuxt Image today; Pinia/Content later). `false` disables
* the whole ecosystem; an object tunes each module, e.g. `{ nuxtUi: false }`. Each module
* auto-gates on its own detection.
*/
nuxtEcosystem?: NuxtEcosystemToggle
Expand All @@ -82,8 +82,6 @@ export interface NustackLintOptions {
markdown?: ConcernToggle<MarkdownConcernOptions>
/** Global rule changes, merged after every concern. */
rules?: Rules
/** @deprecated Use `rules` instead. */
overrides?: Rules
/**
* Detected project context. Injected by the generated `.nuxt/nustack-eslint.mjs`
* for Nuxt apps; the standalone `nustack()` factory fills it via
Expand Down
42 changes: 37 additions & 5 deletions modules/lint/src/configs/nuxt-ecosystem.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,56 @@
import type { NuxtImagePreset, NuxtUiPreset } from '@nustackjs/lint-plugin-nuxt-ecosystem'
import type { Linter } from 'eslint'
import type { NustackContext } from '../context'
import type { ConcernOptions, ConcernToggle } from '../utils'
import { nuxtUiConfigs } from '@nustackjs/lint-plugin-nuxt-ecosystem'
import { nuxtImageConfig, nuxtUiConfig } from '@nustackjs/lint-plugin-nuxt-ecosystem'
import { isEnabled, resolveConcernRules, subOptions } from '../utils'

export type NuxtUiConcernOptions = ConcernOptions
export interface NuxtUiConcernOptions extends ConcernOptions {
/** Rule preset from the Nuxt UI integration. @default 'recommended' */
preset?: NuxtUiPreset | false
}

export interface NuxtImageConcernOptions extends ConcernOptions {
/** Rule preset from the Nuxt Image integration. @default 'recommended' */
preset?: NuxtImagePreset | false
}

/** Options for detected Nuxt ecosystem integrations. */
export interface NuxtEcosystemOptions {
/** Nuxt UI component preferences. Auto-gated on `@nuxt/ui` detection. */
nuxtUi?: ConcernToggle<NuxtUiConcernOptions>
/** Nuxt Image usage rules. Auto-gated on `@nuxt/image` detection. */
nuxtImage?: ConcernToggle<NuxtImageConcernOptions>
}

export type NuxtEcosystemToggle = ConcernToggle<NuxtEcosystemOptions>

/**
* One config per supported module, each included only when that module is installed. Detection
* lives here (the context knows what the project has); the plugin owns the rules and presets.
*/
export function nuxtEcosystemConfig(
context: NustackContext,
options: NuxtEcosystemOptions = {},
): Linter.Config[] {
return isEnabled(options.nuxtUi, context.modules.nuxtUi)
? nuxtUiConfigs({ rules: resolveConcernRules(subOptions(options.nuxtUi)) })
: []
const configs: Linter.Config[] = []

if (isEnabled(options.nuxtUi, context.modules.nuxtUi)) {
const moduleOptions = subOptions(options.nuxtUi)
configs.push(nuxtUiConfig({
prefix: context.nuxtUi.prefix,
preset: moduleOptions.preset,
rules: resolveConcernRules(moduleOptions),
}))
}

if (isEnabled(options.nuxtImage, context.modules.nuxtImage)) {
const moduleOptions = subOptions(options.nuxtImage)
configs.push(nuxtImageConfig({
preset: moduleOptions.preset,
rules: resolveConcernRules(moduleOptions),
}))
}

return configs
}
5 changes: 4 additions & 1 deletion modules/lint/src/context/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function detectStandaloneContext(cwd: string = process.cwd()): NustackCon

return {
modules: resolveModuleFlags(installedModules),
nuxtUi: { ...EMPTY_CONTEXT.nuxtUi },
tailwind: detectTailwind(cwd, resolvable),
autoImports: [],
components: [],
Expand All @@ -70,8 +71,9 @@ export function detectStandaloneContext(cwd: string = process.cwd()): NustackCon
* `EMPTY_CONTEXT`. The manual escape hatch for projects where auto-detection isn't
* enough, e.g. supplying `autoImports`/`components` outside Nuxt.
*/
export type PartialContext = Partial<Omit<NustackContext, 'modules' | 'tailwind'>> & {
export type PartialContext = Partial<Omit<NustackContext, 'modules' | 'nuxtUi' | 'tailwind'>> & {
modules?: Partial<NustackContext['modules']>
nuxtUi?: Partial<NustackContext['nuxtUi']>
tailwind?: Partial<NustackContext['tailwind']>
}

Expand All @@ -80,6 +82,7 @@ export function createContext(partial: PartialContext = {}): NustackContext {
...EMPTY_CONTEXT,
...partial,
modules: { ...EMPTY_CONTEXT.modules, ...partial.modules },
nuxtUi: { ...EMPTY_CONTEXT.nuxtUi, ...partial.nuxtUi },
tailwind: { ...EMPTY_CONTEXT.tailwind, ...partial.tailwind },
}
}
8 changes: 7 additions & 1 deletion modules/lint/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
export interface NustackContext {
modules: {
nuxtUi: boolean
nuxtImage: boolean
/** MDC-capable Markdown renderer (`@nuxt/content`, `@comark/nuxt`, or legacy `@nuxtjs/mdc`). */
mdc: boolean
}
nuxtUi: {
/** Component prefix from `ui.prefix`, needed to recognise `<UButton>` and friends. */
prefix: string
}
tailwind: {
detected: boolean
/**
Expand All @@ -23,7 +28,8 @@ export interface NustackContext {
}

export const EMPTY_CONTEXT: NustackContext = {
modules: { nuxtUi: false, mdc: false },
modules: { nuxtUi: false, nuxtImage: false, mdc: false },
nuxtUi: { prefix: 'U' },
tailwind: { detected: false, entryPoint: null },
autoImports: [],
components: [],
Expand Down
2 changes: 2 additions & 0 deletions modules/lint/src/context/module-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { NustackContext } from '.'
export const MODULE_FLAGS = {
'@nuxt/ui': 'nuxtUi',
'@nuxt/ui-pro': 'nuxtUi',
'@nuxt/image': 'nuxtImage',
'@comark/nuxt': 'mdc',
'@nuxtjs/mdc': 'mdc',
} as const satisfies Record<string, keyof NustackContext['modules']>
Expand All @@ -13,6 +14,7 @@ export function resolveModuleFlags(names: Iterable<string>): NustackContext['mod

const modules: NustackContext['modules'] = {
nuxtUi: false,
nuxtImage: false,
mdc: false,
}
for (const [name, flag] of Object.entries(MODULE_FLAGS)) {
Expand Down
9 changes: 2 additions & 7 deletions modules/lint/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,11 @@ export type Depth = 'quick' | 'full'
export interface ConcernOptions {
/** Per-concern rule changes, merged after the concern's defaults. */
rules?: Rules
/** @deprecated Use `rules` instead. */
overrides?: Rules
}

/** Backward-compatible rule resolver while `rules` becomes the single public API. */
/** Resolves an optional per-concern rule record. */
export function resolveConcernRules(options: ConcernOptions): Rules {
return {
...options.overrides,
...options.rules,
}
return options.rules ?? {}
}

/** A per-concern toggle: `true`/`undefined` = default, `false` = off, object = tune. */
Expand Down
12 changes: 7 additions & 5 deletions modules/lint/test/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { createContext, detectStandaloneContext } from '../src/context/detect'

// Detection now resolves modules through node resolution (exsolve) instead of
// parsing package.json, so a fixture's declared deps aren't automatically installed.
// Mock the resolver to make only `@nuxt/ui` (and only from the nuxt-ui fixture)
// resolvable; the Tailwind entry point is still found by the real CSS scan.
// Mock the resolver to make only `@nuxt/ui` and `@nuxt/image` (and only from the nuxt-ui
// fixture) resolvable; the Tailwind entry point is still found by the real CSS scan.
vi.mock('exsolve', () => ({
resolveModulePath: (id: string, opts: { from?: string }) => {
const from = String(opts?.from ?? '')
if (from.includes('standalone-nuxt-ui') && id === '@nuxt/ui')
return '/virtual/node_modules/@nuxt/ui/index.mjs'
if (from.includes('standalone-nuxt-ui') && (id === '@nuxt/ui' || id === '@nuxt/image'))
return `/virtual/node_modules/${id}/index.mjs`
return undefined
},
}))
Expand All @@ -19,16 +19,18 @@ const NUXT_UI_FIXTURE = fileURLToPath(new URL('./fixtures/standalone-nuxt-ui', i
const PLAIN_FIXTURE = fileURLToPath(new URL('./fixtures/standalone-plain', import.meta.url))

describe('detectStandaloneContext', () => {
it('detects @nuxt/ui (node resolution) and a Tailwind CSS entry point (css scan)', () => {
it('detects @nuxt/ui and @nuxt/image (node resolution) and a Tailwind CSS entry point (css scan)', () => {
const ctx = detectStandaloneContext(NUXT_UI_FIXTURE)
expect(ctx.modules.nuxtUi).toBe(true)
expect(ctx.modules.nuxtImage).toBe(true)
expect(ctx.tailwind.detected).toBe(true)
expect(ctx.tailwind.entryPoint).toBe('assets/css/main.css')
})

it('reports nothing detected for a plain project', () => {
const ctx = detectStandaloneContext(PLAIN_FIXTURE)
expect(ctx.modules.nuxtUi).toBe(false)
expect(ctx.modules.nuxtImage).toBe(false)
expect(ctx.tailwind.detected).toBe(false)
expect(ctx.tailwind.entryPoint).toBeNull()
})
Expand Down
37 changes: 36 additions & 1 deletion modules/lint/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { applyNustackConfig } from '../src/config'
const TAILWIND_ENTRY = fileURLToPath(new URL('./fixtures/tailwind.css', import.meta.url))

const CONTEXT: NustackContext = {
modules: { nuxtUi: true, mdc: false },
modules: { nuxtUi: true, nuxtImage: true, mdc: false },
nuxtUi: { prefix: 'U' },
tailwind: { detected: true, entryPoint: TAILWIND_ENTRY },
autoImports: ['ref', 'useRuntimeConfig'],
components: ['UButton'],
Expand Down Expand Up @@ -47,6 +48,40 @@ describe('e2e: composed config lints real code', () => {
expect(ruleIds(messages)).toContain('@nustack/nuxt-ui/prefer-u-button')
})

it('flags an unlabelled <NuxtImg> when @nuxt/image is detected (nuxt-image concern)', async () => {
const messages = await lint(`<script setup lang="ts"></script><template><NuxtImg src="/hero.png" /></template>`, 'app/pages/x.vue')
expect(ruleIds(messages)).toContain('@nustack/nuxt-image/require-image-alt')
})

it('applies the nuxt-image rules to Nuxt UI wrappers around NuxtImg, at the project prefix', async () => {
const code = `<script setup lang="ts"></script><template><NuColorModeImage light="~/assets/l.png" dark="/d.png" alt="Logo" width="20" height="20" /></template>`
const messages = await lint(
code,
'app/pages/x.vue',
{ context: { ...CONTEXT, nuxtUi: { prefix: 'Nu' } } },
)
expect(ruleIds(messages)).toContain('@nustack/nuxt-image/no-assets-src')
})

it('does not apply nuxt-image rules to UI wrappers when Nuxt UI is disabled', async () => {
const code = `<script setup lang="ts"></script><template><UColorModeImage light="~/assets/l.png" dark="/d.png" /></template>`
const messages = await lint(
code,
'app/pages/x.vue',
{ context: { ...CONTEXT, modules: { ...CONTEXT.modules, nuxtUi: false } } },
)
expect(ruleIds(messages).some(id => id.startsWith('@nustack/nuxt-image/'))).toBe(false)
})

it('leaves the nuxt-image rules out when the module is not installed', async () => {
const messages = await lint(
`<script setup lang="ts"></script><template><NuxtImg src="/hero.png" /></template>`,
'app/pages/x.vue',
{ context: { ...CONTEXT, modules: { ...CONTEXT.modules, nuxtImage: false } } },
)
expect(ruleIds(messages).some(id => id.startsWith('@nustack/nuxt-image/'))).toBe(false)
})

it('flags a redundant explicit auto-import (nuxt concern, fixable)', async () => {
const messages = await lint(`import { ref } from 'vue'\nconst x = ref(1)`, 'app/composables/x.ts')
expect(ruleIds(messages)).toContain('@nustack/nuxt/no-explicit-auto-import')
Expand Down
3 changes: 2 additions & 1 deletion modules/lint/test/enforce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'
import { applyNustackConfig } from '../src/config'

const FULL_CONTEXT: NustackContext = {
modules: { nuxtUi: true, mdc: false },
modules: { nuxtUi: true, nuxtImage: true, mdc: false },
nuxtUi: { prefix: 'U' },
tailwind: { detected: true, entryPoint: 'app/assets/css/main.css' },
autoImports: ['ref'],
components: ['UButton'],
Expand Down
3 changes: 2 additions & 1 deletion modules/lint/test/style.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'
import { applyNustackConfig } from '../src/config'

const FULL_CONTEXT: NustackContext = {
modules: { nuxtUi: true, mdc: false },
modules: { nuxtUi: true, nuxtImage: true, mdc: false },
nuxtUi: { prefix: 'U' },
tailwind: { detected: true, entryPoint: 'app/assets/css/main.css' },
autoImports: ['ref'],
components: ['UButton'],
Expand Down
Loading