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.2 (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
- 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()/PatchElementf()/PatchElementGostar()/RemoveElement()/RemoveElementf()/RemoveElementByID()called withoutWithSelector/WithSelectorIDor with empty/omitted selector argument. Without a CSS selector the JS client throwsPatchElementsNoTargetsFound. Severity: warning.PATCH_SELECTOR_EMPTY—WithSelector("")orWithSelectorID("")— empty string is silently dropped by the SDK. Severity: warning.MERGE_SIGNALS_NIL—MarshalAndPatchSignals(nil)produces"null"on the wire, overwriting all signals. 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 ./ # everythingdatastar-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.