Harlan’s ESLint rules for Vue projects with focus on link hygiene, Nuxt best practices, and Vue reactivity patterns.
|
Made possible by my Sponsor Program 💖 Follow me @harlan_zw 🐦 |
Try the rules in action with a Nuxt ESLint interactive playground:
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:
| Rule | Description |
|---|---|
| Link | |
link-ascii-only |
ensure link URLs contain only ASCII characters |
link-lowercase |
ensure link URLs do not contain uppercase characters |
link-no-double-slashes |
ensure link URLs do not contain consecutive slashes |
link-no-underscores |
ensure link URLs do not contain underscores |
link-no-whitespace |
ensure link URLs do not contain whitespace characters |
link-require-descriptive-text |
require descriptive link text |
link-require-href |
require href/to attribute on link elements |
link-trailing-slash |
enforce trailing slash consistency on URL paths |
| Nuxt | |
nuxt-await-navigate-to |
enforce awaiting navigateTo() calls |
nuxt-no-random |
disallow random values during SSR rendering |
nuxt-no-redundant-component-imports |
disallow #components imports used only as Vue template tags |
nuxt-no-redundant-import-meta |
disallow redundant import.meta.server or import.meta.client checks in scoped components |
nuxt-no-self-layer-alias |
disallow #layers/<name> alias when importing from the same layer; prefer a relative path |
nuxt-no-side-effects-in-async-data-handler |
disallow side effects in async data handlers |
nuxt-no-side-effects-in-setup |
disallow side effects in setup functions |
nuxt-no-unsafe-date |
disallow unstable current dates during SSR rendering |
nuxt-prefer-layer-alias |
prefer #layers/<name> alias over ~~/layers/<name> paths |
nuxt-prefer-navigate-to-over-router-push-replace |
prefer navigateTo() over router.push() or router.replace() |
nuxt-prefer-nuxt-link-over-router-link |
prefer NuxtLink over RouterLink |
nuxt-ui-prefer-shorthand-css |
prefer Nuxt UI shorthand CSS classes over verbose var() syntax |
| Vue | |
vue-no-faux-composables |
stop fake composables that don’t use Vue reactivity |
vue-no-nested-reactivity |
don’t mix ref() and reactive() together |
vue-no-passing-refs-as-props |
don’t pass refs as props; unwrap them first |
vue-no-reactive-destructuring |
avoid destructuring reactive objects |
vue-no-ref-access-in-templates |
don’t use .value in Vue templates |
vue-no-torefs-on-props |
don’t use toRefs() on the props object |
vue-no-reactivity-after-await |
disallow subscription APIs (watch, computed, etc.) after await in async functions |
vue-no-async-lifecycle-hook |
disallow async callbacks in Vue lifecycle hooks |
vue-no-resolve-component-in-composables |
disallow resolveComponent()/resolveDirective() outside top-level <script setup> |
vue-no-unresolvable-define-emits |
disallow unresolvable types in defineEmits type parameters |
vue-prefer-define-emits-object-syntax |
prefer Vue 3.3+ object syntax for defineEmits over call signatures |
vue-require-composable-prefix |
enforce use* prefix for functions using Vue reactivity |
| General | |
no-silent-catch |
disallow silently swallowing errors in .catch() or try/catch |
no-test-file-reads |
warn when tests read files instead of exercising exported behaviour |
prefer-node-style-text |
prefer Node.js styleText() over raw ANSI escape codes |
prefer-satisfies |
prefer satisfies over a widening type annotation on object literals |
| AI Deslop | |
ai-deslop-adverbs |
remove unnecessary adverbs that add no meaning (e.g. “significantly”, “fundamentally”) |
ai-deslop-autolink |
auto-link first mention of known tech terms to their canonical URLs |
ai-deslop-buzzwords |
replace AI-generated buzzword phrases with simpler alternatives (e.g. “leverage” → “use”) |
ai-deslop-casing |
enforce correct casing for tech terms, brands, and abbreviations (e.g. “github” → “GitHub”) |
ai-deslop-false-dichotomy |
flag “it’s not X, it’s Y” contrast patterns common in AI writing |
ai-deslop-false-sincerity |
remove false-sincerity openers that pad sentences (“honestly”, “frankly”, “in all honesty”) |
ai-deslop-filler |
remove AI-generated filler sentences and phrases (e.g. “it’s worth noting that”) |
ai-deslop-hedging |
remove hedging/qualifying words that weaken copy (e.g. “very”, “really”, “quite”, “just”) |
ai-deslop-no-em-dash |
replace em dashes in content prose |
ai-deslop-no-exclamation |
remove exclamation marks from content prose |
ai-deslop-passive-voice |
flag passive voice constructions (e.g. “is generated” → rewrite in active voice) |
ai-deslop-weak-opener |
flag weak sentence openers like “There is” and “It is possible to” |
ai-deslop-frontmatter-spacing |
remove empty lines inside YAML frontmatter |
ai-deslop-code-lang |
require language hints on fenced code examples |
ai-deslop-vue-ts-lang |
require lang="ts" on Vue <script> blocks in code examples |
| pnpm | |
pnpm-require-trust-policy |
require trustPolicyIgnoreAfter: 262800 in pnpm-workspace.yaml |
The plugin also includes 20 prompt linting rules for .prompt.md and .skill.md files. See the prompt configs section below.
Install the plugin:
pnpm add -D eslint-plugin-harlanzw
// 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.
All link rules share ignoreExternal and exclude options. Configure them once:
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,
})
Nuxt and Vue presets enable test rules automatically.
no-test-file-reads warns when tests call Node.js readFile() or readFileSync().
Enable test rules alone with tests: true. Disable them with tests: false.
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.
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:
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 |
Pass additional flat configs as extra arguments:
export default harlanzw(
{ link: true, nuxt: true, vue: true },
{
rules: {
'harlanzw/link-lowercase': ['error', { ignoreExternal: true }],
},
},
)
import antfu from '@antfu/eslint-config'
import { harlanzw } from 'eslint-plugin-harlanzw'
export default antfu(
{ vue: true },
...harlanzw({ link: true, nuxt: true, vue: true }),
)
import { harlanzw } from 'eslint-plugin-harlanzw'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
...harlanzw({ link: true, nuxt: true, vue: true }),
)
The factory exposes the raw plugin and framework detection. The package also exports typed rule maps for custom configs.
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:
import { plugin } from 'eslint-plugin-harlanzw'
export default [
...plugin.configs.link,
...plugin.configs.nuxt,
...plugin.configs.vue,
...plugin.configs.tests,
]
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.
// eslint.config.js
export default harlanzw({
content: true,
})
Or use the raw config:
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 |
20 rules for linting .prompt.md and .skill.md files using a custom prompt language:
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'],
]
Enforces required fields in pnpm-workspace.yaml. Auto-enabled when the file exists.
export default harlanzw({
pnpm: true,
})
Or use the raw config:
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) |
This plugin is based on eslint-plugin-antfu by Anthony Fu.
Licensed under the MIT license.