A multi-language linter for Datastar. Validates HTML attributes AND backend SDK calls across Go, Python, and TypeScript projects.
Datastar's contract lives in data-* attributes on HTML and PatchElements/PatchSignals calls on the backend. This tool catches typos, missing selectors, and misconfigurations at build time — not in the browser console.
Version compatibility: Verified against Datastar v1.0.4 (run
datastar-lint --versionfor the exact value — it is the authoritative source). The rules check stable DOM-level and Datastar-API-level patterns, and the full rule set is covered by automated tests that run in CI on every push. Minor/patch releases of Datastar (Y.Z) should not affect correctness.
- Why datastar-lint
- Install
- Usage
- Available analyzers
- Update flags
- What it catches
- Enabling analyzers
- Architecture
- Go SDK API shape (read before adding a Go rule)
- Where to run it
- License
These mistakes compile and build cleanly — they only fail at runtime, in the browser or over the SSE stream:
- HTML attribute typos (
data-on-clik) — the browser silently ignores unknowndata-*attributes; no build error. - Missing SDK selectors (
SSE.patch_elements()withoutselector=) — the Datastar client throwsPatchElementsNoTargetsFoundat runtime; the SDK does not validate this. - Go
PatchElementfformat mismatch —go builddoes not checkfmtverbs against arguments. MarshalAndPatchSignals(nil)— compiles fine, but sends"null"on the wire, wiping all signals.
datastar-lint shifts these from "caught in production" to "caught in CI".
go install github.com/calionauta/datastar-lint@latestRequires Go 1.26+.
All analyzers (HTML, Go, Python, TypeScript) are included in the binary; only
HTML runs by default — enable the others with --analyzers (see
Enabling analyzers).
# HTML/Templ linting (default analyzer)
datastar-lint -r ./web/
# Go backend linting (opt-in)
datastar-lint -r --analyzers go ./api/
# HTML + Go + cross-reference checks
datastar-lint -r --analyzers html,go ./project/
# Python linting (opt-in)
datastar-lint -r --analyzers python ./src/
# TypeScript linting (opt-in)
datastar-lint -r --analyzers typescript ./src/
# Strict mode: Pro-only attributes become errors
datastar-lint -r -s ./web/
# With custom attribute config
datastar-lint -r --config .datastar-lint.yaml ./src/Run datastar-lint --version to print the linter version and the Datastar release it was verified against. Exit code is 0 on clean, 1 on issues.
| Analyzer | Flag name | Extensions | Default? | Language |
|---|---|---|---|---|
| HTML | html |
.html, .htm, .templ, .tsx, .jsx, .ts, .js |
yes | All — Templ (Go), JSX/TSX (TS/JS), plain HTML |
| Go | go |
.go |
opt-in | Go (stdlib go/parser) |
| Python | python |
.py |
opt-in | Python |
| TypeScript | typescript |
.ts, .tsx |
opt-in | TypeScript/JavaScript |
Two layers in a Go project:
.templis Go's templating format. The HTML analyzer lints thedata-*attributes.templemits (the markup layer), while the separate Go analyzer lints backend SDK calls (PatchElementsselectors, etc.) in.gofiles. Likewise.ts/.tsxare linted by both the HTML analyzer (markup) and the TypeScript analyzer (SDK selectors) when enabled. All analyzers ship in the binary — only HTML runs by default, sodata-*mistakes in TSX/JSX are caught out of the box; enablego/typescriptwith--analyzersfor the SDK-layer checks.
--check-update— Check if a newer version is available on GitHub without running lint.--update— Download and atomically replace the current binary with the latest release. Requires write access to the executable directory.
On every run, datastar-lint silently checks for a newer version (with a 2 second timeout). If found, a notice is printed to stderr before the lint output. Use --update to apply it.
To suppress warnings for intentional custom data-* attributes (e.g., data-tool, data-doc-id), create a .datastar-lint.yaml in your project root:
# .datastar-lint.yaml
attributes:
allowed:
- data-tool
- data-doc-id
- data-app-stateThe linter auto-discovers the file by walking up from the target directory. Pass --config <path> to use an explicit path. The built-in knownAttrs remain the default — the config only adds to them.
The UNKNOWN_ATTR warning says "If this is an intentional custom attribute (not Datastar), add it to .datastar-lint.yaml". This gives LLMs and humans a clear action, but the conditional ("if intentional") makes it explicit that typos and mistakes should be corrected, not silenced. UNKNOWN_ATTR_TYPO (detected misspelling) never mentions the config file — it only suggests the correction.
UNKNOWN_ATTR/UNKNOWN_ATTR_TYPO— Flagsdata-foobar,data-on-clik(typo), or anydata-*attribute not in the Datastar spec.KEY_NOT_ALLOWED— Attributes that don't accept sub-keys (:signalNamesyntax) reject them.INVALID_MODIFIER— Unknown modifiers on Datastar attributes.PRO_ATTR— Datastar Pro-only attributes. Warning by default, error in strict mode.FOREIGN_ATTR— Alpine.js/Vue.js leftovers (x-data,v-if,@click).PATCH_ELEMENTS_NO_ID— Element withdata-on:load(SSE) ordata-on-signal-patchbut noid. The JS client needs an#idanchor to morph the fragment.ON_LOAD_NO_EVENT—data-on:loadon an element that never fires the nativeloadevent (<div>,<span>, etc.). The nativeloadevent only fires on<body>,<img>,<script>,<link>,<video>, and other elements with external resource loading. On all other elements the callback silently never executes. Usedata-initinstead, or add the__windowmodifier. Severity: error.ON_INIT_NO_EVENT—data-on:initused anywhere — there is noinitevent in the browser DOM spec. Usedata-init(without colon) instead, which runs immediately when the element is processed by Datastar. Severity: error.ON_DOM_CONTENT_LOADED_NO_EVENT—data-on:DOMContentLoadedon any element — theDOMContentLoadedevent fires ondocument, not on individual elements, so the callback silently never runs. Usedata-initinstead, or add the__documentmodifier. Severity: error.ON_RESIZE_NO_EVENT—data-on:resizeon any element — the nativeresizeevent only fires onwindow, not on individual elements. For element-level resize observation, useResizeObserverinstead. Or add the__windowmodifier. Severity: error.ON_HASHCHANGE_NO_EVENT—data-on:hashchangeon any element — thehashchangeevent only fires onwindow, not on individual elements. Usedata-on:hashchange__windowinstead. Severity: error.
PATCH_ELEMENTS_NO_SELECTOR—PatchElements()/PatchElementTempl()/PatchElementGostar()/RemoveElement()called withoutWithSelector/WithSelectorID, or with an omitted selector argument. Without a CSS selector the JS client throwsPatchElementsNoTargetsFoundand the update silently never lands. Severity: error (it fails a build).RemoveElementByID()andRemoveElementf()are exempt: the former takes the bare id and prefixes#itself, the latter carries its selector in the format string. Both take no options, so neither can be missing one.PATCH_SELECTOR_EMPTY—WithSelector("")orWithSelectorID("")— empty string is silently dropped by the SDK. Severity: error.MERGE_SIGNALS_NIL—MarshalAndPatchSignals(nil)orMarshalAndPatchSignalsIfMissing(nil)produces"null"on the wire, overwriting all client signals. (TheIfMissingvariant delegates to the base method, so it inherits the defect.) Severity: hint.PATCH_ELEMENTF_FORMAT—PatchElementf()format string has%verbs that may not match the number of value arguments. Severity: hint.GO_PARSE_ERROR— The Go file could not be parsed. Severity: error.
PY_PATCH_NO_SELECTOR—SSE.patch_elements(...)called withoutselector=keyword. Severity: warning.PY_PATCH_EMPTY_SELECTOR—SSE.patch_elements(...)withselector=""orselector=''. Severity: warning.PY_REMOVE_NO_SELECTOR—SSE.remove_elements(...)called with empty or missing selector argument. Severity: warning.
TS_PATCH_NO_SELECTOR—stream.patchElements(...)orsse.patchElements(...)called withoutselector:in options. Severity: warning.TS_PATCH_EMPTY_SELECTOR—selector: ""orselector: ''. Severity: warning.TS_REMOVE_NO_SELECTOR—stream.removeElements(...)/sse.removeElements(...)called with empty or missing selector argument. Severity: warning.
CROSSREF_ORPHAN_SELECTOR— A GoWithSelector("#id")references an element id that doesn't exist in any scanned.templ/.html/.tsx/.jsx/.ts/.jsfile. Severity: warning.
FORM_SUBMIT_MISSING—<form>withdata-bindbut nodata-on:submit.FORM_SUBMIT_NO_PREVENT— Submit action without__preventmodifier.FORM_MISSING_ENCTYPE— File input +contentType: 'form'withoutenctype.BIND_MISSING_NAME— Form element withdata-bindbut noname.BIND_NO_NAME—data-bindwithout signal name.BIND_NON_FORM—data-bindon non-form element without__prop.INDICATOR_AFTER_INIT—data-indicatorafterdata-initon same element.
PARSE_ERROR/FILE_OPEN— File could not be opened or parsed.
All four analyzers are compiled into every binary. Only HTML runs by
default; enable the others with --analyzers (comma-separated):
datastar-lint -r ./ # HTML only (default)
datastar-lint -r --analyzers html,go ./ # + Go SDK checks
datastar-lint -r --analyzers html,typescript ./src/ # + TS SDK checks
datastar-lint -r --analyzers html,go,python,typescript ./ # everythingThe Datastar Go SDK is method-only. Every patch call is a method on
*datastar.ServerSentEventGenerator, obtained from datastar.NewSSE(w, r):
sse := datastar.NewSSE(w, r)
sse.PatchElements(`<div id="x">x</div>`, datastar.WithSelector("#x"))
sse.RemoveElement("#temporary")
sse.MarshalAndPatchSignals(map[string]any{"k": "v"})There is no package-level datastar.PatchElements(sse, ...) — and there has
not been one in any 1.x release. Writing the package form does not compile:
./x.go:11:15: undefined: datastar.PatchElements
Only the option constructors are package-level (datastar.WithSelector,
datastar.WithSelectorID, datastar.WithModeAppend, …). The Go analyzer
therefore matches the method form, gated on the file importing the SDK. Three
of these methods take no options at all, so they cannot be missing a selector:
PatchElementf, RemoveElementf, RemoveElementByID.
datastar-lint uses a plugin-style Analyzer interface:
type Analyzer interface {
Name() string
FileExtensions() []string
Lint(path string, cfg config) []lintResult
}Each analyzer registers itself via init() and RegisterAnalyzer(). The run() function collects files per-analyzer and dispatches linting. When both go and html analyzers run, a cross-reference step automatically checks for orphan selectors.
To add a new language, create a file implementing Analyzer, call RegisterAnalyzer() in init(). (Analyzers currently ship in every build; see Enabling analyzers.)
datastar-lint catches mistakes that language compilers and browsers ignore (see Why datastar-lint). Run it wherever you produce or change Datastar output.
Only the HTML analyzer runs by default. All analyzers ship in the binary,
so enable the others explicitly with --analyzers (comma-separated):
| Analyzer | Runs by default? | To enable |
|---|---|---|
html |
Yes | — (always on) |
go |
No | --analyzers ...,go |
python |
No | --analyzers ...,python |
typescript |
No | --analyzers ...,typescript |
| When | Command | Why |
|---|---|---|
After templ generate |
templ generate && datastar-lint -r --analyzers html,go ./features/ |
Lint generated .templ/.html attributes and Go SDK calls |
| Before commit | datastar-lint -r --analyzers html,go ./ |
Gate local changes across HTML attributes + Go SDK |
| In CI | datastar-lint -r --analyzers html,go ./ |
PR gate; cross-reference runs automatically when go + html are both active |
TypeScript / JavaScript: these have no template code-generation step like
templ generate(Go), so there is no "after generate" trigger. Theirdata-*markup (in.tsx/.jsx/.ts/.js) is linted by the defaulthtmlanalyzer, and their backend SDK calls by thetypescriptanalyzer. Lint both with:datastar-lint -r --analyzers html,typescript ./src(Python SDK calls: addpythonto--analyzers.) TypeScript's owntsc/vitecompile step is unrelated to Datastar codegen.
MIT.
See CHANGELOG.md. Note that PATCH_ELEMENTS_NO_SELECTOR and
PATCH_SELECTOR_EMPTY became errors in v0.12.0 — they now fail a build.