Skip to content

cunarist/deno-lint-plugin-lit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

deno-lint-plugin-lit

Lint rules for Lit Web Components, for deno lint.

Good for letting an AI write your components: the rules catch the Lit mistakes a model makes. Battle-tested on real code.

Setup

// deno.json
{
  "lint": {
    "plugins": ["jsr:@cunarist/deno-lint-plugin-lit/core"]
  }
}

Five plugins. Each says something different about the code it rejects:

Plugin Says Rules
/core This does not do what it looks like it does. 51
/strict Demanding, but we think you should. 13
/dom-ref Reach the DOM through a named ref callback. 3
/reactive-controller Anything with a lifetime belongs in a controller. 7
/naming What things are called. 7

Add the ones you want:

{
  "lint": {
    "plugins": [
      "jsr:@cunarist/deno-lint-plugin-lit/core",
      "jsr:@cunarist/deno-lint-plugin-lit/strict"
    ]
  }
}

Adding a plugin turns on every rule in it. Rule ids are the plugin name plus the rule, so turn one off like this:

{ "lint": { "rules": { "exclude": ["lit-reactive-controller/no-timers"] } } }

All rules assume standard decorators — the TC39 proposal, which is the default in Deno and in TypeScript 5. experimentalDecorators is the older, separate proposal; if you have it switched on, several rules here will be wrong for you.

Picking rules yourself

// lint.ts
import { coreRules } from "jsr:@cunarist/deno-lint-plugin-lit/core";
import { noTimers } from "jsr:@cunarist/deno-lint-plugin-lit/reactive-controller";

const plugin: Deno.lint.Plugin = {
  name: "my-lit",
  rules: { ...coreRules, "no-timers": noTimers },
};

export default plugin;
{ "lint": { "plugins": ["./lint.ts"] } }

Each entry point exports its rules individually plus the whole group as one record: coreRules, strictRules, domRefRules, reactiveControllerRules, namingRules.

/core

The rule to read first is simple-template-expressions: a binding is a name, not an expression. Compute before the return and interpolate by name. Directives included — repeat(...) is a call like any other.

// BAD - all the same violation
html`<div>${repeat(this.items, k, v)}</div>`;
html`<div @click=${() => this.go()}></div>`;
html`<ul>${this.items.map((i) => html`<li>${i}</li>`)}</ul>`;
html`<div>${this.ready ? a : b}</div>`;
html`<div>${this.items[0]}</div>`;

// GOOD
const renderedItems = repeat(this.items, (i) => i.id, this.#renderItem);
return html`<div @click=${this.#onClick}>${renderedItems}</div>`;

This is stricter than Lit's own documentation, which puts conditionals and .map() straight into templates. The trade is that render() becomes a description rather than a program.

Rule Catches
attribute-value-entities an unescaped &, <, > or " inside a static attribute value in an html template
binding-positions a ${…} binding used as a tag name, in a closing tag, or as an attribute name
composed-requires-bubbles an event constructed with composed: true but without bubbles: true
lifecycle-super an override of a Lit lifecycle callback that never calls its own super implementation
no-array-mutation-without-reassign calling a mutating array method on a reactive property
no-async-lifecycle async on a Lit lifecycle hook
no-async-render an async render() on a Lit component
no-attribute-property-binding-conflict the same name bound as both an attribute and a property on one element
no-classfield-shadowing a plain class field whose name matches a reactive property
no-context-mutation-by-consumer assignment to a field declared with @consume
no-dispatch-in-render calling dispatchEvent(...) from render()
no-duplicate-context-provider two @provide declarations for the same context object on one class
no-duplicate-property-declaration a property declared by both a decorator and a static properties entry
no-duplicate-slot-names two <slot> elements with the same name in one template
no-duplicate-tag-registration registering the same custom element tag twice in one file
no-duplicate-template-bindings the same attribute, property, boolean or event binding appearing twice on one element
no-index-as-repeat-key a repeat key function that returns its own index parameter
no-inline-event-attribute HTML inline event handler attributes such as onclick inside an html template
no-inner-html-assignment assignment to innerHTML or outerHTML on any receiver
no-invalid-escape-sequences a malformed \x or \u escape inside an html template
no-invalid-html markup inside an html template that does not parse as valid HTML
no-jsx-attribute-names the JSX attribute names className and htmlFor in a template
no-legacy-imports imports from the Lit 1 module paths lit-html and lit-element, and imports of names that Lit 2 removed
no-legacy-template-syntax Polymer-style [[oneWay]] and {{twoWay}} bindings inside an html template
no-multiple-default-slots two or more unnamed <slot> elements in one template
no-native-attributes a reactive property named after a global HTML attribute
no-partial-property-binding a property, event or boolean binding that is not the entire attribute value
no-private-properties @property on a field declared with a # private name
no-property-change-in-updated assigning to a reactive property inside updated() or firstUpdated()
no-property-change-update assigning to a reactive property inside update()
no-property-named-like-lifecycle a class field or reactive property named after a Lit lifecycle member
no-request-update-in-updated calling this.requestUpdate() inside updated() or firstUpdated()
no-script-in-template a <script> element inside a Lit template
no-self-closing-non-void self-closing syntax on a non-void element in a template
no-this-assign-in-render assigning to anything reached through this inside render()
no-this-in-static-styles this inside a static styles initialiser
no-unsafe-css unsafeCSS imported from Lit — both the import specifier and every call site
no-unsafe-html the unsafeHTML and unsafeSVG directives — both the import specifier and every call site
no-useless-template-literals an html template whose entire content is one binding and no markup
no-value-attribute a bound value=${…} attribute on a form control; use the property binding .value=${…}
prefer-static-styles a <style> element inside an html template
require-accessor-with-decorators a @property or @state decorator on a plain class field instead of an accessor field
require-context-type a createContext() call from @lit/context with no explicit type argument
require-dashed-tag a custom element name the registry will not accept
require-dispatch-on-this a component event dispatched on something other than the component itself
require-property-type a @property that has an attribute but declares no {type: …}
require-repeat-key repeat(items, template) — the two-argument form, with no key function
require-scalar-reflect reflect: true unless the options prove the value is a scalar
simple-template-expressions Every ${…} binding in an html template must be an identifier, this, or a non-computed member chain — nothing else
svg-template-for-svg-content SVG-only elements written directly in an html template with no enclosing <svg>
value-after-constraints a value binding that appears before a validity constraint attribute on the same form control

/strict

Correct, and worth adopting — but they ask more of you than /core does, and a few will fire on code that works. createContext("key") is valid Lit. A class registered from a barrel file looks unregistered here. Light DOM is a real Lit feature that this ruleset declines to use.

Rule Catches
directive-allowlist every import from a Lit directives/ module except lit/directives/ref.js and lit/directives/repeat.js
no-boolean-property-default-true a boolean reactive property whose default is not literal false
no-event-target-subclass a class that extends EventTarget
no-light-dom createRenderRoot() returning this, which renders the component into the light DOM
no-manual-update a Lit component scheduling its own update with requestUpdate, performUpdate, or scheduleUpdate
no-property-assignment-in-constructor assigning a reactive property inside constructor()
no-string-context-key a string literal as the key passed to createContext()
no-update-complete any access to updateComplete
prefer-context-decorators constructing a ContextProvider or ContextConsumer by hand inside a Lit component
prefer-decorators a static properties declaration on a Lit component
require-custom-element-registration a LitElement subclass that is never registered, with neither @customElement nor customElements.define
require-event-in-event-map an event constructed inside a Lit component whose name has no matching HTMLElementEventMap entry in the same file
require-tag-name-map a component registered with @customElement that has no matching HTMLElementTagNameMap entry in the same file

/dom-ref

One idea: the only way to reach an element is a ref bound to a createRef.

@query answers two questions badly: which element, and when.

  • Which — a selector matches by shape. @query("input") means "whichever input comes first". Add one above it and you silently get a different element.
  • When — it is a lazy querySelector. It answers any time you ask, including null before the first render.

A ref settles both: it is bound to one position, and Lit fills its createRef as the element attaches and clears it as it goes away.

// BAD
class Bad extends LitElement {
  @query("#input")
  accessor input;

  override render() {
    return html`<input id="input">`;
  }
}

// GOOD
class Good extends LitElement {
  #input = createRef<HTMLInputElement>();

  override render() {
    const inputRef = ref(this.#input);
    return html`<input ${inputRef}>`;
  }
}
Rule Catches
no-dom-query querySelector and querySelectorAll inside a Lit component
no-query-decorators @query, @queryAll, @queryAsync, @queryAssignedElements, and @queryAssignedNodes, and their imports from the Lit decorator modules
prefer-create-ref a ref callback that only stashes the element, in favour of createRef

/reactive-controller

A ReactiveController is a unit of behaviour with no UI. It keeps components small, pairs acquisition with release so a resource cannot leak, and turns "watch this element's size" into something named and reusable.

class ItemsController implements ReactiveController {
  #host: ReactiveControllerHost;
  #abort: AbortController | undefined;

  constructor(host: ReactiveControllerHost) {
    this.#host = host;
    host.addController(this);
  }

  hostConnected(): void {
    this.#abort = new AbortController();
  }

  hostDisconnected(): void {
    this.#abort?.abort();
    this.#abort = undefined;
  }
}

Components keep only styles and render. Everything else — listeners, timers, sockets, fetches — moves here, where hostConnected and hostDisconnected sit next to each other.

Rule Catches
host-constructor a controller constructor that is not exactly constructor(host: ReactiveControllerHost)
lifecycle-allowlist any lifecycle override on a Lit component other than styles and render
no-component-disposables constructing AbortController, EventSource, IntersectionObserver, MutationObserver, ResizeObserver, WebSocket, or Worker inside a Lit component, and rejects calling addEventListener or removeEventListener there
no-fetch-in-component a fetch(...) call inside a Lit component class
no-timers setTimeout, setInterval, requestAnimationFrame, and queueMicrotask inside a Lit component
paired-lifecycle a controller that defines hostConnected or hostDisconnected but not both
self-registration a controller that does not call host.addController(this) in its constructor

/naming

What things are called. Some of these are pure convention — a reader can tell an element from a controller from a plain class at a glance. Others catch a real trap: an uppercase attribute or event name is silently lowercased or never matched, so the binding quietly does nothing. Either way the fix is a rename, so they live together.

Rule Catches
attribute-names a camelCase reactive property that does not declare an explicit attribute option
event-name-case an event name that is not kebab-case — uppercase or _ — in new CustomEvent(...)/new Event(...)
no-camelcase-attribute an attribute name that is not kebab-case — uppercase or _
require-controller-suffix a reactive controller class whose name does not end in Controller
require-element-suffix a registered component class whose name does not end in Element
require-tag-prefix a custom element name that carries no namespace segment
tag-matches-class-name a @customElement tag whose segments, after any leading prefix, do not PascalCase to the class name

Known limits

  • No rule options. Deno's plugin API has no way to configure a rule, so none of these take settings.
  • One file at a time. Rules that need a superclass chain or a registration site only see the current module.
  • Unclosed tags are under-reported. <li>, <td>, and <p> may legally omit their closing tag, so no-invalid-html leaves them alone.

License

MIT. See LICENSE for third-party attribution.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages