eslint-plugin-harlanzw
eslint-plugin-harlanzw
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 🐦 |
Playground
Try the rules in action with a Nuxt ESLint interactive 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
| 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 |
| Prompt | |
prompt-dangling-path |
flag a backticked repository path in an instruction file that does not exist |
| Docs | |
docs-work-brief-contract |
require every open brief in docs/work to carry the fields that make it auditable |
docs-reference-no-status |
forbid a Status line in a reference document, where location already carries status |
docs-root-allowlist |
forbid new Markdown at the repository root outside the root-docs allowlist |
docs-retired-pointer |
flag a pointer to a document that has been retired, naming its replacement |
| pnpm | |
pnpm-require-trust-policy |
require trustPolicyIgnoreAfter: 262800 in pnpm-workspace.yaml |
| Nuxt UI design, opt in | |
nuxt-ui-no-restyle |
use component props and site-approved classes for Nuxt UI appearance |
vue-no-dynamic-tailwind-classes |
use complete Tailwind class names in Vue bindings |
The plugin also includes 21 prompt linting rules for .prompt.md and .skill.md files. See the prompt configs section below.
Installation
Install the plugin:
pnpm add -D eslint-plugin-harlanzw
Usage
// 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:
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.
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 |
Extra Configs
Pass additional flat configs as extra arguments:
export default harlanzw(
{ link: true, nuxt: true, vue: true },
{
rules: {
'harlanzw/link-lowercase': ['error', { ignoreExternal: true }],
},
},
)
With @antfu/eslint-config
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
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:
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
:classand:uibindings.
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 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:
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: compiler-backed checks, custom hooks, and layer styles.
- Theme tokens: known token equivalents and optional color and spacing policies.
- Component props: 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.
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,
]
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.
// 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 |
Prompt Rules
21 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'],
]
pnpm Rules
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) |
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.
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:
export default [
{
rules: {
'harlanzw/docs-work-brief-contract': ['error', {
dir: 'docs/initiatives',
buckets: ['Waiting', 'Go'],
require: ['title', 'doneMeans'],
}],
},
},
]
Sponsors
Credits
This plugin is based on eslint-plugin-antfu by Anthony Fu.
License
Licensed under the MIT license.
Automatic detection skips known Nuxt UI versions below v4. Explicit nuxtUi configuration overrides detection.
Related Packages
An ESLint plugin to enforce consistent usage of 'data-testid' attributes in Vue....
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.23.0 | ^3.5.42 | MIT | |
| 0.22.0 | ^3.5.42 | MIT | |
| 0.21.1 | ^3.5.42 | MIT | |
| 0.21.0 | ^3.5.42 | MIT | |
| 0.20.0 | ^3.5.41 | MIT | |
| 0.19.2 | ^3.5.41 | MIT | |
| 0.19.1 | ^3.5.41 | MIT | |
| 0.19.0 | ^3.5.41 | MIT | |
| 0.18.1 | ^3.5.41 | MIT | |
| 0.18.0 | ^3.5.41 | MIT | |
| 0.17.1 | ^3.5.40 | MIT | |
| 0.17.0 | ^3.5.34 | MIT | |
| 0.16.1 | ^3.5.34 | MIT | |
| 0.16.0 | ^3.5.34 | MIT | |
| 0.15.1 | ^3.5.34 | MIT | |
| 0.15.0 | ^3.5.34 | MIT | |
| 0.14.3 | ^3.5.34 | MIT | |
| 0.14.2 | ^3.5.34 | MIT | |
| 0.14.1 | ^3.5.34 | MIT | |
| 0.14.0 | ^3.5.34 | MIT | |
| 0.13.0 | ^3.5.34 | MIT | |
| 0.12.3 | ^3.5.34 | MIT | |
| 0.12.1 | ^3.5.32 | MIT | |
| 0.12.0 | ^3.5.30 | MIT | |
| 0.11.0 | ^3.5.30 | MIT | |
| 0.10.4 | ^3.5.30 | MIT | |
| 0.10.3 | ^3.5.30 | MIT | |
| 0.10.2 | ^3.5.30 | MIT | |
| 0.10.1 | ^3.5.30 | MIT | |
| 0.10.0 | ^3.5.30 | MIT |
Showing the latest 30 of 68. See every release on npm