eslint-plugin-lit-ui-router
v1.2.1
Published
ESLint rules that understand lit-ui-router directives: hrefless anchors, inert hrefs, aria-current conflicts and misplaced directives
Readme
eslint-plugin-lit-ui-router
ESLint rules that understand lit-ui-router directives.
A lit-ui-router anchor carries no static href — the element-part directive assigns one at runtime — so stock accessibility rules report every correct call site. These rules keep the base rules' real coverage while understanding what the directives do.
The rest are the directives' own runtime dev warnings, statically: the inert href on a native non-link, the silent aria-current takeover, the aria-current neither the class directive nor the status controller ever writes, and the constructor throw each directive keeps for the part type it does not accept. Each reports at author time, on the whole codebase, in a production build — where the runtime says nothing.
Install
npm install --save-dev eslint-plugin-lit-ui-routereslint (^9.0.0 || ^10.0.0) is the only peer dependency. anchor-is-valid is vendored from eslint-plugin-lit-a11y, not wrapped around it: lit-a11y is an optional sibling you may also run, never a requirement.
Usage
Flat config (eslint.config.js):
import litUiRouter from 'eslint-plugin-lit-ui-router';
export default [...litUiRouter.configs.recommended];With lit-a11y alongside, configs.recommended must come after lit-a11y's config: it turns lit-a11y/anchor-is-valid off in favor of ours, which reports the same three messages plus the uiSref carve-out.
import litA11y from 'eslint-plugin-lit-a11y';
import litUiRouter from 'eslint-plugin-lit-ui-router';
export default [
litA11y.configs.recommended,
...litUiRouter.configs.recommended,
];Without lit-a11y installed, that off line is inert — flat config accepts a severity for an unregistered plugin's rule — so there is nothing to change either way.
Settings
Every rule only looks inside lit templates, and settings.litHtmlSources — lit-a11y's setting, with the same three modes — decides which tagged templates those are:
- unset (the default): every template tagged
htmlis analysed, whateverhtmlis imported from, plus aliases and namespaces (h,lit.html) imported fromlit,lit-htmlorlit-element. true: only files importinglit,lit-htmlorlit-elementare analysed, and only templates whose tag resolves to that import.- an array: as
true, with the listed packages added to the accepted sources.
export default [
...litUiRouter.configs.recommended,
{ settings: { litHtmlSources: ['@apollo-elements/lit-apollo'] } },
];Tags resolve through scope, so a parameter or local named html or uiSref never counts as the import. This is stricter than lit-a11y in one place: once a file is gated in, lit-a11y also accepts html aliases and namespaces from any later import, while these rules only accept them from the listed sources — a wrapper module that re-exports html belongs in the array.
settings.linkElements names the tags that are link elements — a design system's <sp-link>, <my-link> — which three rules would otherwise have to guess at:
export default [
...litUiRouter.configs.recommended,
{ settings: { linkElements: ['sp-link'] } },
];anchor-is-validchecks a declared element the way it checks<a>: it wants a navigablehref, static or assigned by auiSrefelement part.sref-assign-hrefgoes quiet on a declared element, the way it does on<a>— it honours thehrefthat theassignHref: truedefault writes.sref-active-class-aria-currentasks a declared element for thearia-currentit asks an<a>for.sref-status-aria-currentasks the same of a declared element whose classes read aSrefStatusController.
Undeclared tags are unchanged in every rule, so this is purely additive: with no linkElements, each behaves exactly as it did. Each also takes a linkElements option of its own, which replaces the setting for that rule.
settings.allowElementParts says whether an element part counts as the work it does at runtime. @lit-labs/ssr never runs an element part, so a template a server prerenders serves <a ${uiSref('home')}> with no href, and <a ${uiSrefActive(...)}> with neither its active class nor aria-current, until the client hydrates. With false, two rules lint a template as the server renders it:
anchor-is-validstops counting auiSrefelement part as thehref, and fixes it tohref=${srefHref(...)}.sref-active-class-aria-currentasks a link carrying auiSrefActiveelement part forclass=${srefActiveClass(...)}witharia-current=${srefAriaCurrent(...)}.
Unset is true, so with no allowElementParts each behaves exactly as it did. Each also takes an allowElementParts option of its own, which replaces the setting for that rule.
Prerendered templates
The rules cannot tell which templates a server renders, so the setting is scoped with files. A mostly-client app turns it off for its prerendered templates:
export default [
...litUiRouter.configs.recommended,
{ files: ['src/prerendered/**'], settings: { allowElementParts: false } },
];A fully isomorphic app turns it off everywhere and back on for its client-only files:
export default [
...litUiRouter.configs.recommended,
{ settings: { allowElementParts: false } },
{ files: ['src/client-only/**'], settings: { allowElementParts: true } },
];oxlint's overrides take no settings, so in .oxlintrc.json the setting is top-level and an override scopes the rule option, which replaces it:
{
"settings": { "allowElementParts": false },
"overrides": [
{
"files": ["src/client-only/**"],
"rules": {
"lit-ui-router/anchor-is-valid": ["error", { "allowElementParts": true }],
"lit-ui-router/sref-active-class-aria-current": [
"error",
{ "allowElementParts": true }
]
}
}
]
}A mostly-client app leaves the top-level setting out and sets false in the override for its prerendered templates.
oxlint (alpha)
The rules also load into oxlint as JS plugins — every one is syntax-only, with no type information. oxlint does not consume configs.recommended, so list them explicitly in .oxlintrc.json:
{
"jsPlugins": ["eslint-plugin-lit-ui-router"],
"rules": {
"lit-ui-router/anchor-is-valid": "error",
"lit-ui-router/directive-position": "error",
"lit-ui-router/sref-active-aria-current": "error",
"lit-ui-router/sref-active-class-aria-current": "error",
"lit-ui-router/sref-assign-href": "error",
"lit-ui-router/sref-status-aria-current": "error"
}
}No lit-a11y off line is needed: oxlint ships no lit-a11y rules. eslint is imported type-only, so an oxlint-only host does not need ESLint installed.
oxlint's jsPlugins is alpha and explicitly outside semver. This package's test:oxlint lane gates against the exact pinned oxlint version in its own devDependencies and claims nothing beyond it; a break in a later oxlint surfaces on that bump, not in your lint run.
Running ESLint and oxlint together
ESLint-only, oxlint-only and both-at-once are all supported. If you run both, the recommended split is oxlint owns every lit-ui-router/* rule — it is the fast lane, and one report is better than two — while ESLint keeps only the lit-a11y off.
.oxlintrc.json — the rules run here:
{
"jsPlugins": ["eslint-plugin-lit-ui-router"],
"rules": {
"lit-ui-router/anchor-is-valid": "error",
"lit-ui-router/directive-position": "error",
"lit-ui-router/sref-active-aria-current": "error",
"lit-ui-router/sref-active-class-aria-current": "error",
"lit-ui-router/sref-assign-href": "error",
"lit-ui-router/sref-status-aria-current": "error"
}
}eslint.config.js — do not spread configs.recommended; turn lit-a11y's rule off by hand:
import litA11y from 'eslint-plugin-lit-a11y';
import oxlint from 'eslint-plugin-oxlint';
export default [
litA11y.configs.recommended,
{
// oxlint owns every lit-ui-router/* rule; this displaces lit-a11y's.
rules: { 'lit-a11y/anchor-is-valid': 'off' },
},
oxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),
];The manual off is the whole trick: eslint-plugin-oxlint de-duplicates only oxlint's native rule names, and has no handling for jsPlugins rules at all. Spreading configs.recommended on top of the oxlint lane would register every rule a second time, and every finding would report twice.
This repository runs exactly this split — see .oxlintrc.json and eslint.config.ts.
Optionally, exempt test fixtures, whose elements exist to be driven rather than shipped, and whose specs often import uiSref by relative path (which the rule cannot recognise as ours):
{
"overrides": [
{
"files": ["**/*.spec.ts", "**/src/specs/**"],
"rules": {
"lit-ui-router/anchor-is-valid": "off",
"lit-ui-router/sref-assign-href": "off"
}
}
]
}Rules
💼 Configurations enabled in.
✅ Set in the recommended configuration.
🔧 Automatically fixable by the --fix CLI option.
| Name | Description | 💼 | 🔧 | | :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | :- | :- | | anchor-is-valid | anchor-is-valid for lit templates, where a uiSref element part counts as its runtime href unless allowElementParts is false, which fixes it to srefHref | ✅ | 🔧 | | directive-position | require each lit-ui-router directive to sit in the template position its part type allows | ✅ | | | sref-active-aria-current | disallow an authored aria-current on an element a uiSrefActive element part manages | ✅ | 🔧 | | sref-active-class-aria-current | require an aria-current binding beside a srefActiveClass binding on a link element, or a uiSrefActive element part when allowElementParts is false | ✅ | 🔧 | | sref-assign-href | require assignHref: 'auto' when a uiSref element part rides a native element with no href | ✅ | 🔧 | | sref-status-aria-current | require an aria-current binding on a link element whose classes read a SrefStatusController | ✅ | 🔧 |
Semver policy
Following ESLint core's own policy: a change that makes recommended or an existing rule stricter — new reports on code that previously passed — ships as a major.
New router surface is the exception. lit-ui-router keeps adding directives, and a rule that can only fire on one of them reports on nothing anybody could already have written — the API it matches did not exist yet. Those rules are additive, so they ship as a minor. The attribute-part tier is the current example: sref-active-class-aria-current, sref-status-aria-current and directive-position's attribute rows match only srefHref, srefActiveClass, srefAriaCurrent and SrefStatusController imported from lit-ui-router, all new in [email protected]. A codebase on an earlier router cannot trip them, which is why they arrive in 1.1 rather than 2.0.
What bounds the exception is the surface a rule can reach, not how rare a report would be in practice. A new report reachable through uiSref or uiSrefActive — shipped since 1.0 — is a major however unlikely it is, as is a tightened option default, and so is a rule for router API that shipped but went undocumented: the line is that the router did not export it, not that nobody used it.
The option-aware tier (sref-assign-href, sref-active-aria-current, directive-position) widened recommended during the 1.0.0 release candidates, where a widening cost nobody a major. The remaining roadmap (a state-aware tier) is the same trajectory, so expect majors, not silent tightening.
Module format and Node support
Published as ESM only. Flat config loads ESM natively; a CommonJS config can require() this package on Node ^20.19.0 or >=22.12.0; the call returns the module namespace, so read the plugin from its .default. The engines range mirrors what the ESLint peer range itself supports, not this repository's own Node version.
The template-analyzer deep import
The rule imports eslint-plugin-lit/lib/template-analyzer.js, a path with no exports-map guarantee, against eslint-plugin-lit@^2.0.0 (a regular dependency, installed for you). That is the same path and range eslint-plugin-lit-a11y itself relies on — a break there breaks lit-a11y first — and this package's test suite runs against the pinned floor versions, so a break surfaces on the bump, not in your lint run.
