Releases: PrairieLearn/tree-sitter-htmlmustache
Release list
v1.3.0
What's Changed
- Add recursive parent-owned child schemas for customTags.
- Support strict and loose direct-child validation with Mustache sections treated as transparent.
- Allow child-only tags to stay scoped to declaring parents unless listed top-level.
- Document child schema configuration and diagnostics.
v1.2.0
API changes
- Added tag validator ergonomics for project-level custom validation.
- Updated validator docs and examples for the new API shape.
Parser fixes
- Improved literal
<handling in text so less-than characters before real tags parse cleanly. - Added corpus and linter coverage for the less-than parsing cases.
Release maintenance
- Bumped the parser package and VS Code extension to 1.2.0.
- Refreshed
package-lock.jsonso the npm lock metadata matches the current package API surface.
Checks
pnpm run lintpnpm run format:checkpnpm run typecheckpnpm run test:jspnpm run build:jspnpm --prefix lsp run typecheckpnpm --prefix lsp run testpnpm --prefix lsp run build:productionpnpm test
v1.1.0
Custom tag validators
Custom tag validation now separates attribute schemas from richer project rules:
- Tag schemas are draft-06 JSON Schemas over the flat attribute object only.
pluginModulereplacesajvModuleand can exportformatsand synchronousvalidators.- Validator ids behave like lint rule ids, including severity overrides and inline disables.
- Validators receive one-level child facades with mustache-section flattening and optional raw
innerHtml.
Breaking validation changes
- Removed draft 2020-12 schema support.
- Removed the element-envelope schema shape (
tag,attributes,children,text,innerHtml). - Removed custom AJV keyword registration and
htmlGlobalAttributesexpansion. - Schema deprecation diagnostics now apply to flat attribute schemas only.
Cleanup
- Removed unused
aria-attributesandhtml-element-attributesdependencies.
Checks
pnpm run lintpnpm run format:checkpnpm run typecheckpnpm run test:jspnpm run build:jspnpm --prefix lsp run typecheckpnpm --prefix lsp run testpnpm --prefix lsp run build:productionpnpm test
v1.0.4
customTagDeprecations lint rule
JSON Schema's deprecated: true is an annotation, not a validator — AJV silently ignores it. A new lint rule (default severity warning) walks each custom-tag element + its registered schema in parallel and surfaces deprecations at four levels, folding any sibling description into the diagnostic as the reason:
- Tag —
<pl-legacy> is deprecated. Use <pl-question-panel> instead. - Attribute —
Attribute "old-name" on <pl-card> is deprecated. Renamed to "answers-name". - Attribute value —
Value "legacy" for attribute "kind" on <pl-card> is deprecated. Use "new".(matched viaoneOf/anyOfconstbranches; skipped for mustache-interpolated values) - Child tag in context —
<pl-answer-old> as a child of <pl-choice> is deprecated.(matched viaproperties.children.itemsbranches keyed byproperties.tag.const)
Structural deprecation (tag, attribute) walks only unconditional composition (root + allOf) — branches inside anyOf/oneOf/if describe alternate value validations and don't structurally deprecate the attribute they live on.
Disable per-element via the existing infrastructure: <!-- htmlmustache-disable customTagDeprecations -->.
Schema diagnostics: collapse composition-wrapper noise
With AJV's allErrors: true, a single bad attribute like correct="trsue" against an anyOf: [boolean, string-with-format-pl-boolean] schema used to produce three diagnostics — the boolean-branch type failure plus two raw AJV strings without attribute context. They now collapse to one:
Attribute "correct" on <pl-answer> child of <pl-multiple-choice> must be boolean or match format "pl-boolean".
Errors are grouped by instancePath and dispatched on the wrapper keyword: anyOf/oneOf join their translated branch phrases with "or"; if/allOf wrappers are dropped (pure bookkeeping over their branches); not translates generically; an ajv-errors errorMessage at any path overrides the merged result.
format, pattern, minLength, maxLength, exclusiveMinimum, and exclusiveMaximum are now translated with attribute context too.
LSP: schema-aware completion
Inside any custom-tag element the language server now offers, driven by the tag's registered schema:
- Attribute names — every property the schema allows (across
allOf/anyOf/oneOfandif/thenbranches), excluding attributes already present on the element. Required attributes are flagged. - Attribute values — concrete candidates from
enum,const,examples,default, andtype: "boolean"declarations across all branches. When no concrete values are available, surfaces the declaredformatname as a hint.
No new config surface — drives directly off the existing customTags[*].schema.
v1.0.2
Schema validator extensions
New envelope fields (text, innerHtml)
The per-element value validated against custom-tag schemas now exposes two new fields, on both the top-level element and each child:
text— concatenated descendant text. HTML tags stripped (<b>foo</b>contributes"foo"); mustache interpolations preserved verbatim ({{name}}stays in the string). Surrounding whitespace trimmed; interior whitespace preserved.innerHtml— raw source between the open and close tags, byte-for-byte. Empty for self-closing or void elements.
This makes content-shaped rules expressible directly in the schema — non-empty content via minLength, no duplicate inner text across children via uniqueItems, regex/length checks over the raw markup — without dropping back to selector-based custom rules or consumer-side post-processing.
createLinter({ keywords })
Register consumer-defined ajv keywords on the schema validator. Thin pass-through to ajv.addKeyword — the key becomes the keyword name. The diagnostic rewriter detects custom-keyword errors and passes the consumer's message through unchanged (same path as ajv-errors); when no message is set, falls back to a generic <tag>: validation <keyword> failed on <path>.
Config-file surface: formatsModule → ajvModule
Breaking change. The .htmlmustache.jsonc formatsModule field is renamed to ajvModule and now reads two named exports: formats and keywords. The v1.0.1 default-export-as-formats shim is gone — modules must use named exports:
// before (v1.0.1)
export default { 'pl-boolean': /* ... */ };
// after (v1.0.2)
export const formats = { 'pl-boolean': /* ... */ };
export const keywords = { 'unique-child-text': /* ... */ };SchemaKeyword and SchemaFormat are re-exported from ./linter so consumers can type their definitions without reaching into ajv internals.
v1.0.1
Schema formats
Two new ways to register ajv formats so custom-tag schemas can use { "format": "..." } value rules:
createLinter({ formats })— programmatic hook, passed through toloadSchemaRegistryandajv.addFormat.formatsModulein.htmlmustache.jsonc— relative path to a JS/TS module whose default export isRecord<string, SchemaFormat>. The CLI and the language server dynamically import it once per process. Load failures surface ascustomTagSchemadiagnostics.
loadConfigFile and loadConfigFileForPath are now async (the dynamic import happens during config load).
Documentation
- Worked example in the README showing how to express parent-conditional child-attribute constraints in plain JSON Schema 2020-12 (no DSL extensions). Solves the recurring "when parent has attr X, child of tag Y must not have attr Z" pattern.
- Notes on the trust boundary
formatsModuleintroduces (the CLI / extension runs that file in-process).
Internal
- ADR directory removed.
v1.0.0
First stable release. Headline changes since v0.9.3:
Breaking — package layout
The JS source moved to js/ and the package now exposes three dedicated
entry points instead of a single browser export:
| Old | New |
|---|---|
@reteps/tree-sitter-htmlmustache/browser |
@reteps/tree-sitter-htmlmustache/parser, /linter, /formatter |
createLinter().format(...) |
createFormatter().format(...) |
cli/out/main.js |
dist/cli/main.js (bin shim unaffected) |
Rationale and migration notes: docs/adr/0001-restructure-js-entry-points.md.
New — custom-tag attribute validation
Custom tags can now declare attributes and types via JSON Schema. The
linter surfaces unknown/missing/typed-mismatch attributes as HTML-shaped
diagnostics (unknown-attribute, missing-required-attribute, etc.),
with optional per-rule custom messages. See the linter docs for the
customTags config key.
Improved — global attribute list
The list of HTML global attributes is now sourced from
html-element-attributes + aria-attributes instead of a hand-maintained
list, so ARIA + standard global attributes stay current with upstream.
Other
- Docs corrected:
htmlGlobalAttributesexample,customTagskey, pnpm
in the LSP README. - Repo-wide prettier + eslint pass over the new
js/layout.
🤖 Generated with Claude Code
v0.9.3
Fix: multi-segment :not() in custom lint rules
:not(...) with a combinator inside it was silently dropped by the selector engine. Given a rule like:
{
"id": "require-img-class-or-style",
"selector": "img:not([style]):not([class]):not(pl-overlay img)",
"message": "Annotate <img> or keep it under <pl-overlay>."
}the third negation (:not(pl-overlay img)) was ignored because checkSelfNegations bailed on any alternative with more than one segment. Only simple compound selectors on the element itself worked inside :not().
What works now
:not(X) accepts any multi-segment selector, tested against the node's ancestor/sibling context (matching Element.matches semantics):
img:not(pl-overlay img)— descendanttd:not(thead > td)— direct childp:not(h2 + p)— adjacent siblingbutton:not({{#admin}} button)— crosses into a Mustache sectionsection:has(img:not(pl-overlay img))— composes inside:has()
Simple forms (:not(div), :not([attr]), :not(.cls), :not(:has(...))) are unchanged.
Scope
Affects the selector matcher shared by the CLI (htmlmustache check), the LSP/VS Code extension, and the browser API.
v0.9.2
Per-rule include/exclude for custom lint rules
Custom rules now accept optional `include` and `exclude` glob arrays, applied as an intersection on top of the top-level `include`/`exclude`. A rule fires for a file only when both the top-level filter admits it AND the per-rule patterns match. `exclude` takes precedence over `include`.
```jsonc
{
"include": ["/*.mustache"],
"customRules": [
{
"id": "pl-input-in-panel",
"include": ["questions/"],
"exclude": ["/legacy/"],
"selector": ":is(pl-question-panel, pl-answer-panel) :is(pl-string-input, pl-integer-input)",
"message": "Move input elements outside panel tags."
}
]
}
```
Paths are matched relative to the `.htmlmustache.jsonc` directory, with separators normalized to forward slashes for cross-platform pattern portability.
Scope
- CLI (`htmlmustache check`): filter applied per file.
- LSP / VS Code extension: filter applied per document URI.
- Browser API (`createLinter().lint`): `include`/`exclude` are stripped from the browser `CustomRule` type because the browser has no filesystem path context. TypeScript will reject the fields at the browser API boundary — strip them before passing a shared config.
v0.9.1
New selector features for custom lint rules
:is(a, b, ...) grouping
Expanded at parse time into the Cartesian product of alternatives. Cuts selector boilerplate dramatically:
":is(pl-question-panel, pl-answer-panel, pl-submission-panel) :is(pl-big-o-input, pl-integer-input, pl-string-input)"Sibling combinators + and ~
Work across HTML and Mustache constructs, skipping whitespace/text per CSS semantics:
label + input— adjacent HTML siblings{{foo}} + p— Mustache interpolation followed by an element{{#items}} + {{#other}}— adjacent Mustache sectionsh2 ~ {{foo}}— any later sibling of<h2>that is{{foo}}
:not(...) accepts Mustache literals and type selectors
Enables whole-node negation against the current segment:
{{*}}:not({{internal.*}}) — any interpolation except internal.*
:not(div) — any element that isn't a div
Attribute / class / id / :has inside :not(...) still take the existing fast path.