# eslint-plugin-harlanzw

[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![License][license-src]][license-href]

Harlan's ESLint rules for Vue projects with focus on link hygiene, Nuxt best practices, and Vue reactivity patterns.

<p align="center">
<table>
<tbody>
<td align="center">
<sub>Made possible by my <a href="https://github.com/sponsors/harlan-zw">Sponsor Program 💖</a><br> Follow me <a href="https://twitter.com/harlan_zw">@harlan_zw</a> 🐦</sub><br>
</td>
</tbody>
</table>
</p>

## Playground

Try the rules in action with a Nuxt ESLint interactive playground:

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/harlan-zw/eslint-plugin-harlanzw/tree/main/playground)

## Rules

> **Note:** These rules are experimental and may change. They will be submitted to the official Vue ESLint plugin for consideration.

The rules are organized into the following categories:

- **Link Rules**: Ensure link URLs are clean, accessible, and SEO-friendly
- **Nuxt Rules**: Best practices for Nuxt applications
- **Vue Rules**: Vue composition API and reactivity best practices
- **AI Deslop Rules**: Clean AI-generated slop from content markdown
- **Prompt Rules**: Lint agent prompt files for dangling paths, dilution, and structure
- **Docs Rules**: Enforce the root-docs contract across a repository's documentation

<!-- rules:start -->
| Rule | Description |
| --- | --- |
| **Link** | |
| [`link-ascii-only`](./src/rules/link-ascii-only.md) | ensure link URLs contain only ASCII characters |
| [`link-lowercase`](./src/rules/link-lowercase.md) | ensure link URLs do not contain uppercase characters |
| [`link-no-double-slashes`](./src/rules/link-no-double-slashes.md) | ensure link URLs do not contain consecutive slashes |
| [`link-no-underscores`](./src/rules/link-no-underscores.ts) | ensure link URLs do not contain underscores |
| [`link-no-whitespace`](./src/rules/link-no-whitespace.md) | ensure link URLs do not contain whitespace characters |
| [`link-require-descriptive-text`](./src/rules/link-require-descriptive-text.ts) | require descriptive link text |
| [`link-require-href`](./src/rules/link-require-href.ts) | require `href`/`to` attribute on link elements |
| [`link-trailing-slash`](./src/rules/link-trailing-slash.md) | enforce trailing slash consistency on URL paths |
| **Nuxt** | |
| [`nuxt-await-navigate-to`](./src/rules/nuxt-await-navigate-to.md) | enforce awaiting `navigateTo()` calls |
| [`nuxt-no-random`](./src/rules/nuxt-no-random.md) | disallow random values during SSR rendering |
| [`nuxt-no-redundant-component-imports`](./src/rules/nuxt-no-redundant-component-imports.md) | disallow `#components` imports used only as Vue template tags |
| [`nuxt-no-redundant-import-meta`](./src/rules/nuxt-no-redundant-import-meta.md) | disallow redundant `import.meta.server` or `import.meta.client` checks in scoped components |
| [`nuxt-no-self-layer-alias`](./src/rules/nuxt-no-self-layer-alias.ts) | disallow `#layers/<name>` alias when importing from the same layer; prefer a relative path |
| [`nuxt-no-side-effects-in-async-data-handler`](./src/rules/nuxt-no-side-effects-in-async-data-handler.md) | disallow side effects in async data handlers |
| [`nuxt-no-side-effects-in-setup`](./src/rules/nuxt-no-side-effects-in-setup.md) | disallow side effects in setup functions |
| [`nuxt-no-unsafe-date`](./src/rules/nuxt-no-unsafe-date.md) | disallow unstable current dates during SSR rendering |
| [`nuxt-prefer-layer-alias`](./src/rules/nuxt-prefer-layer-alias.ts) | prefer `#layers/<name>` alias over `~~/layers/<name>` paths |
| [`nuxt-prefer-navigate-to-over-router-push-replace`](./src/rules/nuxt-prefer-navigate-to-over-router-push-replace.md) | prefer `navigateTo()` over `router.push()` or `router.replace()` |
| [`nuxt-prefer-nuxt-link-over-router-link`](./src/rules/nuxt-prefer-nuxt-link-over-router-link.md) | prefer `NuxtLink` over `RouterLink` |
| [`nuxt-ui-prefer-shorthand-css`](./src/rules/nuxt-ui-prefer-shorthand-css.ts) | prefer Nuxt UI shorthand CSS classes over verbose `var()` syntax |
| **Vue** | |
| [`vue-no-faux-composables`](./src/rules/vue-no-faux-composables.md) | stop fake composables that don't use Vue reactivity |
| [`vue-no-nested-reactivity`](./src/rules/vue-no-nested-reactivity.md) | don't mix `ref()` and `reactive()` together |
| [`vue-no-passing-refs-as-props`](./src/rules/vue-no-passing-refs-as-props.md) | don't pass refs as props; unwrap them first |
| [`vue-no-reactive-destructuring`](./src/rules/vue-no-reactive-destructuring.md) | avoid destructuring reactive objects |
| [`vue-no-ref-access-in-templates`](./src/rules/vue-no-ref-access-in-templates.md) | don't use `.value` in Vue templates |
| [`vue-no-torefs-on-props`](./src/rules/vue-no-torefs-on-props.md) | don't use `toRefs()` on the props object |
| [`vue-no-reactivity-after-await`](./src/rules/vue-no-reactivity-after-await.md) | disallow subscription APIs (`watch`, `computed`, etc.) after `await` in async functions |
| [`vue-no-async-lifecycle-hook`](./src/rules/vue-no-async-lifecycle-hook.md) | disallow async callbacks in Vue lifecycle hooks |
| [`vue-no-resolve-component-in-composables`](./src/rules/vue-no-resolve-component-in-composables.ts) | disallow `resolveComponent()`/`resolveDirective()` outside top-level `<script setup>` |
| [`vue-no-unresolvable-define-emits`](./src/rules/vue-no-unresolvable-define-emits.ts) | disallow unresolvable types in `defineEmits` type parameters |
| [`vue-prefer-define-emits-object-syntax`](./src/rules/vue-prefer-define-emits-object-syntax.ts) | prefer Vue 3.3+ object syntax for `defineEmits` over call signatures |
| [`vue-require-composable-prefix`](./src/rules/vue-require-composable-prefix.ts) | enforce `use*` prefix for functions using Vue reactivity |
| **General** | |
| [`no-silent-catch`](./src/rules/no-silent-catch.md) | disallow silently swallowing errors in `.catch()` or `try/catch` |
| [`no-test-file-reads`](./src/rules/no-test-file-reads.md) | warn when tests read files instead of exercising exported behaviour |
| [`prefer-node-style-text`](./src/rules/prefer-node-style-text.ts) | prefer Node.js `styleText()` over raw ANSI escape codes |
| [`prefer-satisfies`](./src/rules/prefer-satisfies.md) | prefer `satisfies` over a widening type annotation on object literals |
| **AI Deslop** | |
| [`ai-deslop-adverbs`](./src/prompt/rules/deslop-adverbs.ts) | remove unnecessary adverbs that add no meaning (e.g. "significantly", "fundamentally") |
| [`ai-deslop-autolink`](./src/prompt/rules/deslop-autolink.ts) | auto-link first mention of known tech terms to their canonical URLs |
| [`ai-deslop-buzzwords`](./src/prompt/rules/deslop-buzzwords.ts) | replace AI-generated buzzword phrases with simpler alternatives (e.g. "leverage" → "use") |
| [`ai-deslop-casing`](./src/prompt/rules/deslop-casing.ts) | enforce correct casing for tech terms, brands, and abbreviations (e.g. "github" → "GitHub") |
| [`ai-deslop-false-dichotomy`](./src/prompt/rules/deslop-false-dichotomy.ts) | flag "it's not X, it's Y" contrast patterns common in AI writing |
| [`ai-deslop-false-sincerity`](./src/prompt/rules/deslop-false-sincerity.ts) | remove false-sincerity openers that pad sentences ("honestly", "frankly", "in all honesty") |
| [`ai-deslop-filler`](./src/prompt/rules/deslop-filler.ts) | remove AI-generated filler sentences and phrases (e.g. "it's worth noting that") |
| [`ai-deslop-hedging`](./src/prompt/rules/deslop-hedging.ts) | remove hedging/qualifying words that weaken copy (e.g. "very", "really", "quite", "just") |
| [`ai-deslop-no-em-dash`](./src/prompt/rules/deslop-no-em-dash.ts) | replace em dashes in content prose |
| [`ai-deslop-no-exclamation`](./src/prompt/rules/deslop-no-exclamation.ts) | remove exclamation marks from content prose |
| [`ai-deslop-passive-voice`](./src/prompt/rules/deslop-passive-voice.ts) | flag passive voice constructions (e.g. "is generated" → rewrite in active voice) |
| [`ai-deslop-weak-opener`](./src/prompt/rules/deslop-weak-opener.ts) | flag weak sentence openers like "There is" and "It is possible to" |
| [`ai-deslop-frontmatter-spacing`](./src/prompt/rules/deslop-frontmatter-spacing.ts) | remove empty lines inside YAML frontmatter |
| [`ai-deslop-code-lang`](./src/prompt/rules/deslop-code-lang.ts) | require language hints on fenced code examples |
| [`ai-deslop-vue-ts-lang`](./src/prompt/rules/deslop-vue-ts-lang.ts) | require `lang="ts"` on Vue `<script>` blocks in code examples |
| **Prompt** | |
| [`prompt-dangling-path`](./src/prompt/rules/prompt-dangling-path.ts) | flag a backticked repository path in an instruction file that does not exist |
| **Docs** | |
| [`docs-work-brief-contract`](./src/prompt/rules/docs-work-brief-contract.ts) | require every open brief in docs/work to carry the fields that make it auditable |
| [`docs-reference-no-status`](./src/prompt/rules/docs-reference-no-status.ts) | forbid a Status line in a reference document, where location already carries status |
| [`docs-root-allowlist`](./src/prompt/rules/docs-root-allowlist.ts) | forbid new Markdown at the repository root outside the root-docs allowlist |
| [`docs-retired-pointer`](./src/prompt/rules/docs-retired-pointer.ts) | flag a pointer to a document that has been retired, naming its replacement |
| **pnpm** | |
| [`pnpm-require-trust-policy`](./src/prompt/rules/pnpm-require-trust-policy.ts) | require `trustPolicyIgnoreAfter: 262800` in `pnpm-workspace.yaml` |
| **Nuxt UI design, opt in** | |
| [`nuxt-ui-no-restyle`](./src/rules/nuxt-ui-no-restyle.md) | use component props and site-approved classes for Nuxt UI appearance |
| [`vue-no-dynamic-tailwind-classes`](./src/rules/vue-no-dynamic-tailwind-classes.md) | use complete Tailwind class names in Vue bindings |
<!-- rules:end -->

The plugin also includes 21 **prompt linting** rules for `.prompt.md` and `.skill.md` files. See the [prompt configs](#prompt-rules) section below.

## Installation

Install the plugin:

```bash
pnpm add -D eslint-plugin-harlanzw
```

## Usage

```js
// eslint.config.js
import { harlanzw } from 'eslint-plugin-harlanzw'

export default harlanzw({
  link: true,
  nuxt: true,
  vue: true,
})
```

The Nuxt and Vue presets cover `.vue`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts` files.

### Link Options

All link rules share `ignoreExternal` and `exclude` options. Configure them once:

```js
export default harlanzw({
  link: {
    ignoreExternal: true, // skip http(s):// URLs and elements with `external` attr
    exclude: ['^/api/', '/OAuth/'], // skip URLs matching any regex pattern
    requireTrailingSlash: true, // passed to link-trailing-slash
  },
  nuxt: true,
  vue: true,
})
```

### Test Rules

Nuxt and Vue presets enable test rules automatically.
`no-test-file-reads` warns when tests read source files or files with unknown paths.
Known non-source paths, such as generated `.json`, `.md`, and `.txt` files, are allowed.

Enable test rules alone with `tests: true`. Disable them with `tests: false`.

### Shared Base

`base` carries the override blocks that were copy-pasted into every repo: the shared ignore set, node globals, and relaxations for test files, markdown code fences, and example manifests. It is opt in.

```js
import antfu from '@antfu/eslint-config'
import { harlanzw } from 'eslint-plugin-harlanzw'

export default antfu(
  { type: 'lib' },
  ...harlanzw({
    base: true, // or { type: 'app', ignores: ['docs/**'] }
    nuxt: true,
    vue: true,
  }),
)
```

`type` defaults to `'lib'`, which also turns off `ts/explicit-function-return-type`. `ignores` appends to the shared set. Every shared ignore glob is recursive, so nested playgrounds and fixtures in a monorepo are covered.

Pass `ignores: false` to drop the shared ignore block and declare your own. A global ignore cannot be undone by a later config, so this is the only way to keep linting something the shared set covers, such as a playground you lint on purpose. The rule blocks still apply.

`agentFiles` decides what happens to the prompts you wrote: `CLAUDE.md`, `AGENTS.md`, and `.cursor`. It defaults to `'ignore'`, keeping them out of the lint run. A global ignore beats any `files`-scoped config, so `harlanzw()` switches it to `'lint'` whenever its prompt config is enabled, otherwise the prompt rules would never see those files. Set it explicitly to override that.

The `.claude` directory is always ignored, whatever `agentFiles` says. Nothing in it is a prompt this repo wrote: its skills are upstream package docs installed by skilld, its context is generated notes and job state, and agents drop whole repo checkouts in there. It also sits in its own block, so `ignores: false` does not hand it back.

Every rule in these blocks is set to `off`, and flat config ignores an `off` entry for a rule whose plugin is absent. So the blocks are safe with any preset, and need no dependency on `@antfu/eslint-config`.

Spread them without the rule presets when you only want the shared overrides:

```js
import { base } from 'eslint-plugin-harlanzw'

export default antfu({ type: 'lib' }, ...base())
```

The base blocks come first in the returned array, so put `harlanzw()` after the preset whose rules it relaxes.

Contents:

| Block | Applies to | Turns off |
| --- | --- | --- |
| `harlanzw/base/agent-ignores` | global | `.claude` always, plus `CLAUDE.md`, `AGENTS.md`, `.cursor` when `agentFiles` is `'ignore'` |
| `harlanzw/base/ignores` | global | `.data`, fixtures, `playground`, `worker-configuration.d.ts`, plus your `ignores`. Dropped by `ignores: false` |
| `harlanzw/base/rules` | all files | `no-use-before-define`, `node/prefer-global/process`, `node/prefer-global/buffer` (+ `ts/explicit-function-return-type` for libs) |
| `harlanzw/base/tests` | test files | `no-console`, `ts/no-unsafe-function-type`, `antfu/no-top-level-await`, `e18e/prefer-static-regex` |
| `harlanzw/base/markdown` | `**/*.md/**` | `no-console`, tabs, `style/max-statements-per-line`, `e18e/prefer-static-regex`, unused imports |
| `harlanzw/base/examples` | `examples/**/package.json` | pnpm catalog rules |

### Extra Configs

Pass additional flat configs as extra arguments:

```js
export default harlanzw(
  { link: true, nuxt: true, vue: true },
  {
    rules: {
      'harlanzw/link-lowercase': ['error', { ignoreExternal: true }],
    },
  },
)
```

### With @antfu/eslint-config

```js
import antfu from '@antfu/eslint-config'
import { harlanzw } from 'eslint-plugin-harlanzw'

export default antfu(
  { vue: true },
  ...harlanzw({ link: true, nuxt: true, vue: true }),
)
```

### With Nuxt ESLint

```ts
import { harlanzw } from 'eslint-plugin-harlanzw'
import withNuxt from './.nuxt/eslint.config.mjs'

export default withNuxt(
  ...harlanzw({ link: true, nuxt: true, vue: true }),
)
```

### Nuxt UI Design Rules

Enable our design rules through your site's existing Nuxt ESLint config:

```js
import { harlanzw } from 'eslint-plugin-harlanzw'
import withNuxt from './.nuxt/eslint.config.mjs'

export default withNuxt(
  ...harlanzw({ nuxt: true, vue: true, nuxtUi: true }),
)
```

`nuxtUi` enables automatically when the current package declares `@nuxt/ui` in dependencies or devDependencies.
Use `nuxtUi: false` to disable it. Use `nuxtUi: true` when another workspace package owns the dependency.
It adds two Vue template checks:

- Warn when Nuxt UI controls or configured wrappers override component appearance.
- Report partial Tailwind class construction in `:class` and `:ui` bindings.

Messages guide agents toward component props and shared styling.
The rules never change appearance automatically.
No runtime Nuxt module or shadcn dependency is needed.

Pass an object to configure component names, allowed classes, slots, sizes, and repair messages.
See [site configuration](./src/rules/nuxt-ui-no-restyle.md#site-configuration) for a complete example and coverage limits.
Size and variant guidance comes from these explicit options. App configuration is not executed or automatically discovered.

### Theme-aware checks

Load the site's CSS to validate utility names, colors, and spacing:

```js
import { tailwind } from 'eslint-plugin-harlanzw/tailwind'

export default withNuxt(
  ...harlanzw(),
  await tailwind({ stylesheet: './app/assets/css/main.css' }),
)
```

Install `@tailwindcss/node@~4.3.3` for this optional helper.
It uses your CSS imports, theme tokens, and component styles.

- [Valid classes](./src/rules/vue-valid-tailwind-classes.md): compiler-backed checks, custom hooks, and layer styles.
- [Theme tokens](./src/rules/vue-prefer-theme-tokens.md): known token equivalents and optional color and spacing policies.
- [Component props](./src/rules/nuxt-ui-no-restyle.md): size, color, variant, and configured wrapper values.

OG-image templates are excluded from theme checks by default because their renderer has separate styling rules.

### Public API

The factory exposes the raw plugin and framework detection. The package also exports typed rule maps for custom configs.

```ts
import type { RuleOptions, Rules } from 'eslint-plugin-harlanzw'
import { harlanzw, plugin } from 'eslint-plugin-harlanzw'

const detected = harlanzw.detectFramework()
const rawPlugin = harlanzw.plugin
const linkOptions: RuleOptions['link-lowercase'] = [{ ignoreExternal: true }]
const linkRule: Rules['link-lowercase'] = ['warn', ...linkOptions]

export default [
  ...plugin.configs.recommended,
  { rules: { 'harlanzw/link-lowercase': linkRule } },
]
```

You can also select individual presets:

```js
import { plugin } from 'eslint-plugin-harlanzw'

export default [
  ...plugin.configs.link,
  ...plugin.configs.nuxt,
  ...plugin.configs.vue,
  ...plugin.configs.tests,
]
```

### AI Deslop Rules

15 rules for cleaning AI-generated slop from your content markdown files (`content/**/*.md`). Most rules are auto-fixable. Prose checks skip fenced code blocks opened with backticks or tildes, including fences longer than three characters.

```js
// eslint.config.js
export default harlanzw({
  content: true,
})
```

Or use the raw config:

```js
import { plugin } from 'eslint-plugin-harlanzw'

export default [
  ...plugin.configs.content,
]
```

| Rule | What it does |
| --- | --- |
| `ai-deslop-buzzwords` | Replaces overused AI phrases with plain alternatives ("leverage" → "use", "delve into" → "explore") |
| `ai-deslop-filler` | Removes filler phrases that add nothing ("it's worth noting that", "at the end of the day") |
| `ai-deslop-adverbs` | Strips unnecessary adverbs ("significantly", "fundamentally", "essentially") |
| `ai-deslop-casing` | Fixes tech term casing using a 300+ term dictionary ("github" → "GitHub", "typescript" → "TypeScript") |
| `ai-deslop-autolink` | Links first mention of tech terms to their canonical URLs ("Nuxt" → `[Nuxt](https://nuxt.com)`) |
| `ai-deslop-false-dichotomy` | Flags "it's not X, it's Y" false contrast patterns |
| `ai-deslop-false-sincerity` | Strips false-sincerity openers ("honestly", "frankly", "in all honesty", "let's be real") |
| `ai-deslop-hedging` | Strips hedging words that weaken copy ("very", "really", "quite", "just", "somewhat") |
| `ai-deslop-no-em-dash` | Replaces em dashes in content prose |
| `ai-deslop-no-exclamation` | Replaces exclamation marks with periods in content prose |
| `ai-deslop-passive-voice` | Flags passive voice ("is generated", "was created") for active rewriting |
| `ai-deslop-weak-opener` | Flags weak expletive openers ("There is", "It is possible to") |
| `ai-deslop-frontmatter-spacing` | Removes empty lines inside YAML frontmatter blocks |
| `ai-deslop-code-lang` | Adds language hints to fenced code blocks |
| `ai-deslop-vue-ts-lang` | Adds `lang="ts"` to Vue `<script>` blocks in code examples |

### Prompt Rules

21 rules for linting `.prompt.md` and `.skill.md` files using a custom prompt language:

```js
import { plugin } from 'eslint-plugin-harlanzw'

export default [
  ...plugin.configs['prompt:recommended'],
  // or stricter:
  // ...plugin.configs['prompt:strict'],
  // for skill files:
  // ...plugin.configs['prompt:skill'],
]
```

### pnpm Rules

Enforces required fields in `pnpm-workspace.yaml`. Auto-enabled when the file exists.

```js
export default harlanzw({
  pnpm: true,
})
```

Or use the raw config:

```js
import { plugin } from 'eslint-plugin-harlanzw'

export default [
  ...plugin.configs.pnpm,
]
```

| Rule | What it does |
| --- | --- |
| `pnpm-require-trust-policy` | Ensures `trustPolicyIgnoreAfter: 262800` is present in `pnpm-workspace.yaml` (auto-fixable) |

### Docs Rules

Opt-in rules for the root-docs contract: an allowlist for Markdown at the repository root and a lifecycle for `docs/`. Enable them per repository, since the contract is a convention rather than a fact about Markdown.

```js
import { plugin } from 'eslint-plugin-harlanzw'

export default [
  ...plugin.configs.docs,
]
```

**This config is opinionated and opt-in.** It encodes one convention about where Markdown lives
and what a brief must say. Nothing in it is enabled by `recommended`, `content`, or any
`prompt:*` config, and a test pins that. Every default below is an option, because a repository
adopting the config will not share every opinion in it.

`docs-root-allowlist` and `prompt-dangling-path` apply to root `*.md` files only.
`docs-work-brief-contract` and `docs-reference-no-status` apply to `docs/**/*.md`.
`docs-retired-pointer` applies to both root `*.md` and `docs/**/*.md`:

| Rule | What it does | Key options |
| --- | --- | --- |
| `docs-work-brief-contract` | Requires every open brief in `docs/work/` to carry title, `Status:`, `**Next move:**`, `Done means:`, `## Ledger`, and `## Log` fields | `dir`, `buckets`, `require` |
| `docs-reference-no-status` | Forbids a `Status:` line in reference documents, where the folder already carries status | `dirs` |
| `docs-root-allowlist` | Forbids new Markdown at the repository root outside the root-docs allowlist | `allow`, `additionalAllow`, `root` |
| `docs-retired-pointer` | Flags a pointer to a retired document and names its replacement | `retired` |
| `prompt-dangling-path` | Flags a backticked repository path that does not exist | `root`, `ignore` |

The defaults are Harlan's. `**Next move:**` accepts `Harlan`, `Blocked` or `Ready`, and a
bucket named after a person is the clearest sign to set your own:

```js
export default [
  {
    rules: {
      'harlanzw/docs-work-brief-contract': ['error', {
        dir: 'docs/initiatives',
        buckets: ['Waiting', 'Go'],
        require: ['title', 'doneMeans'],
      }],
    },
  },
]
```

## Sponsors

<p align="center">
  <a href="https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg">
    <img src='https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg' alt="Sponsors"/>
  </a>
</p>

## Credits

This plugin is based on [eslint-plugin-antfu](https://github.com/antfu/eslint-plugin-antfu) by Anthony Fu.

## License

Licensed under the [MIT license](https://github.com/harlan-zw/eslint-plugin-harlanzw/blob/main/LICENSE).

<!-- Badges -->

[npm-version-src]: https://img.shields.io/npm/v/eslint-plugin-harlanzw?style=flat&colorA=080f12&colorB=1fa669
[npm-version-href]: https://npmjs.com/package/eslint-plugin-harlanzw
[npm-downloads-src]: https://img.shields.io/npm/dm/eslint-plugin-harlanzw?style=flat&colorA=080f12&colorB=1fa669
[npm-downloads-href]: https://npmjs.com/package/eslint-plugin-harlanzw

[license-src]: https://img.shields.io/github/license/harlan-zw/eslint-plugin-harlanzw.svg?style=flat&colorA=080f12&colorB=1fa669
[license-href]: https://github.com/harlan-zw/eslint-plugin-harlanzw/blob/main/LICENSE

Automatic detection skips known Nuxt UI versions below v4. Explicit `nuxtUi` configuration overrides detection.
