Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ syntax or APIs; a patch bump does not. See
runtime spread keeps bag keys by the same rule: it now accepts `&`, and drops
C1 controls, noncharacters, `{`, `}`, backticks and invalid UTF-8 that it
previously emitted.
- **Escaping** — the `htmx` URL preset covers htmx 4: `hx-query` and
`hx-action` join the five method attributes, and every name is also
sanitized in its `:inherited`, `:append` and `:inherited:append` spellings,
which htmx 4 reads through the same lookup. One preset serves htmx 2 and 4.
- **Runtime** — a url preset is now a predicate compiled into the runtime:
`gsx.AttrSinks` gains `Presets gsx.URLPreset`, and generated spread sites
carry `Presets: _gsxrt.PresetHTMX` instead of a name list, so a preset's
coverage can change without touching generated code.
- **Editor** — `hx-*` completion is the union of the htmx 2 and htmx 4
attribute tables; an attribute only one version has says so in its hover
text, and `hx-disable` documents both meanings. (#199)

## v0.1.0 — 2026-09-03

Expand Down
17 changes: 13 additions & 4 deletions docs/guide/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,15 +277,24 @@ them.

### Presets `url_presets` {#url_presets-named-opt-in-rulesets}

Use the `htmx` preset when htmx method attributes carry application URLs:
Use the `htmx` preset when htmx request attributes carry application URLs:

```toml
url_presets = ["htmx"]
```

It classifies `hx-get`, `hx-post`, `hx-put`, `hx-delete`, and `hx-patch` as URL
attributes. Other htmx attributes such as `hx-target`, `hx-swap`, and
`hx-trigger` stay plain. Unknown preset names are configuration errors.
It classifies `hx-get`, `hx-post`, `hx-put`, `hx-delete`, `hx-patch`,
`hx-query`, and `hx-action` as URL attributes, in each of the spellings htmx 4
reads: the plain name, `:inherited`, `:append`, and `:inherited:append`
(for example `hx-action:inherited`). One preset serves htmx 2 and htmx 4; the
htmx 4 only names are never used by an htmx 2 page, so enabling them costs
nothing there. Other htmx attributes such as `hx-target`, `hx-swap`,
`hx-trigger`, and `hx-method` stay plain. Unknown preset names are
configuration errors.

The preset matches attribute names exactly. If htmx is configured with a
custom `prefix` or `metaCharacter`, list the renamed attributes under
[`[url_attrs]`](#url_attrs-user-declared-url-attributes) yourself.

`url_presets` is top-level, so place it before `[filters]`, `[formatter]`, or
any other table.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ supports tree-sitter.
| Document symbols | File components and top-level Go declarations. |
| Workspace symbols | Module components and top-level Go declarations. |
| Code actions | Organize imports and choose missing imports. |
| Completion | Go identifiers and members, pipe filters, component tags and attributes, HTML tags/attributes/values, and `hx-*` attributes when htmx is enabled. |
| Completion | Go identifiers and members, pipe filters, component tags and attributes, HTML tags/attributes/values, and `hx-*` attributes when the `htmx` URL preset is enabled. The `hx-*` table covers htmx 2 and htmx 4; an attribute only one version has says so in its hover text. |

Completion returns plain text edits, not snippets. Completing a symbol on a
package you have not imported — `ui.Button` in a Go expression, or `<ui.Button`
Expand Down
5 changes: 3 additions & 2 deletions docs/guide/syntax/escaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ a dynamic value cannot break out of the quoted attribute.

URL attributes include `href`, `src`, `action`, `formaction`, `poster`, `cite`,
`ping`, `data`, `background`, `manifest`, `xlink:href`, `srcset`, and
`imagesrcset`. The htmx method attributes `hx-get`, `hx-post`, `hx-put`,
`hx-delete`, and `hx-patch` join this set when you enable the `htmx` URL preset;
`imagesrcset`. The htmx request attributes `hx-get`, `hx-post`, `hx-put`,
`hx-delete`, `hx-patch`, `hx-query`, and `hx-action`, with their `:inherited`
and `:append` spellings, join this set when you enable the `htmx` URL preset;
see [Config](../config.md#url_presets-named-opt-in-rulesets).

Relative URLs and the `http`, `https`, `mailto`, and `tel` schemes pass through.
Expand Down
4 changes: 1 addition & 3 deletions gen/configfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,9 @@ func loadConfig(path string) (config, error) {
}
}
for _, name := range tc.URLPresets {
rules, ok := attrclass.Preset(name)
if !ok {
if _, ok := attrclass.Preset(name); !ok {
return config{}, fmt.Errorf("%s: url_presets: unknown preset %q (known: %s)", path, name, strings.Join(attrclass.PresetNames(), ", "))
}
cfg.urlRules = cfg.urlRules.Merge(rules.URL)
cfg.urlPresets = append(cfg.urlPresets, name)
}
if tc.Minify != nil {
Expand Down
4 changes: 2 additions & 2 deletions gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type config struct {
jsFmt rawfmt.Formatter
urlRules attrclass.RuleSet
urlTagRules map[string]attrclass.RuleSet // url rules scoped to one element
urlPresets []string // names of enabled url-attribute presets (e.g. "htmx"); retained ALONGSIDE their expansion into urlRules so the LSP can tell which presets are on
urlPresets []string // names of enabled url-attribute presets (e.g. "htmx"): a preset is a predicate the classifier and runtime apply by name
errs []error
printWidth int // gsx.toml [formatter] print_width; 0 means "unset" → pretty.DefaultPrintWidth at use
tabWidth int // gsx.toml [formatter] tab_width; 0 means "unset" → pretty.DefaultTabWidth at use
Expand Down Expand Up @@ -129,7 +129,7 @@ func (c config) effectiveJSONMin() func(string) (string, error) {
// classifier builds the resolved Classifier from the accumulated options. A
// config with no attr options yields a built-ins-only Classifier.
func (cfg *config) classifier() *attrclass.Classifier {
return attrclass.New(attrclass.Rules{URL: cfg.urlRules, URLTags: cfg.urlTagRules})
return attrclass.New(attrclass.Rules{URL: cfg.urlRules, URLTags: cfg.urlTagRules, Presets: cfg.urlPresets})
}

// Main is the gsx process entry point: it builds a config from opts (currently
Expand Down
18 changes: 9 additions & 9 deletions gen/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,21 +347,21 @@ func WithURLAttrsOn(tag string, rules RuleSet) Option {
}
}

// WithURLPreset enables one or more named URL-attribute presets, appending each
// preset's URL rules onto the config (additive over the built-in floor, exactly
// like WithURLAttrs). The only preset today is "htmx", which re-classifies the
// five htmx method attributes (hx-get/post/put/delete/patch) as URL sinks — they
// are OFF by default. An unknown preset name is recorded as a config error so the
// run fails with a clear message instead of silently doing nothing.
// WithURLPreset enables one or more named URL-attribute presets (additive over
// the built-in floor, exactly like WithURLAttrs). A preset is a predicate the
// classifier and the runtime spread leaf apply by name. The only preset today
// is "htmx", which classifies the htmx request-URL attributes
// (hx-get/post/put/delete/patch, hx-query, hx-action, and their :inherited /
// :append spellings) as URL sinks — they are OFF by default. An unknown preset
// name is recorded as a config error so the run fails with a clear message
// instead of silently doing nothing.
func WithURLPreset(names ...string) Option {
return func(cfg *config) {
for _, name := range names {
rules, ok := attrclass.Preset(name)
if !ok {
if _, ok := attrclass.Preset(name); !ok {
cfg.errs = append(cfg.errs, fmt.Errorf("WithURLPreset: unknown preset %q (known: %s)", name, strings.Join(attrclass.PresetNames(), ", ")))
continue
}
cfg.urlRules = cfg.urlRules.Merge(rules.URL)
cfg.urlPresets = append(cfg.urlPresets, name)
}
}
Expand Down
109 changes: 89 additions & 20 deletions internal/attrclass/attrclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
// extend it additively via declarative Rules, wired through gen.Main. Rules are
// DECLARATIVE on purpose: classification must be fully enumerable, because the
// set has to travel to the spread leaf as data and has to be hashable into the
// codegen cache key. The same Classifier is consulted by the parser (JS facet,
// to split @{ } holes) and by codegen (all facets, for context-aware escaping).
// codegen cache key. A named preset keeps that property while matching by
// predicate: the predicate is compiled into htmlattr and the runtime, and only
// the preset's NAME travels — as a flag to the spread leaf and as a string
// into the fingerprint. The same Classifier is consulted by the parser (JS
// facet, to split @{ } holes) and by codegen (all facets, for context-aware
// escaping).
package attrclass

import (
Expand Down Expand Up @@ -128,11 +132,46 @@ type Rules struct {
URL RuleSet `json:"url,omitzero"`
CSS RuleSet `json:"css,omitzero"`
URLTags map[string]RuleSet `json:"urlTags,omitempty"`
// Presets names the enabled url presets (see Preset). A preset classifies
// by a predicate in htmlattr rather than by listed names; its name is the
// enumerable, hashable identity that stands in for the predicate here.
Presets []string `json:"presets,omitempty"`
}

// Merge returns the union of r and other: every RuleSet merged, tag scopes
// merged per element, presets appended without duplicates. Rules are additive,
// so merging is how a preset, a config file and a programmatic option compose.
func (r Rules) Merge(other Rules) Rules {
out := Rules{
JS: r.JS.Merge(other.JS),
URL: r.URL.Merge(other.URL),
CSS: r.CSS.Merge(other.CSS),
Presets: append([]string(nil), r.Presets...),
}
if len(r.URLTags) > 0 || len(other.URLTags) > 0 {
out.URLTags = make(map[string]RuleSet, len(r.URLTags)+len(other.URLTags))
for _, src := range []map[string]RuleSet{r.URLTags, other.URLTags} {
for tag, set := range src {
out.URLTags[tag] = out.URLTags[tag].Merge(set)
}
}
}
for _, p := range other.Presets {
if !slices.Contains(out.Presets, p) {
out.Presets = append(out.Presets, p)
}
}
return out
}

// Valid checks every set, naming the context so a config error points at the
// offending table.
// offending table, and rejects a preset name no predicate exists for.
func (r Rules) Valid() error {
for _, p := range r.Presets {
if _, ok := presets[p]; !ok {
return fmt.Errorf("url_presets: unknown preset %q (known: %s)", p, strings.Join(PresetNames(), ", "))
}
}
for _, group := range []struct {
ctx string
set RuleSet
Expand Down Expand Up @@ -193,8 +232,8 @@ func (c *Classifier) Context(tag, name string) Context {
}

// 2. User declarative rules — the global sets, plus the URL rules this
// element scopes.
if c.rules.URL.matches(ln) || c.rules.URLTags[strings.ToLower(tag)].matches(ln) {
// element scopes — and the enabled presets' predicates.
if c.rules.URL.matches(ln) || c.rules.URLTags[strings.ToLower(tag)].matches(ln) || c.presetURL(ln) {
return CtxURL
}
if c.rules.CSS.matches(ln) {
Expand Down Expand Up @@ -250,25 +289,55 @@ func (c *Classifier) UserURLRules(tag string) RuleSet {
}
}

// presets maps a named opt-in ruleset to the classification Rules it contributes.
// Presets compose additively over the built-in floor, exactly like user rules;
// they are enabled via gen.WithURLPreset / gsx.toml url_presets.
// presets maps a preset name to its URL predicate over an already-lowercased
// attribute name. Presets compose additively over the built-in floor, exactly
// like user rules; they are enabled via gen.WithURLPreset / gsx.toml
// url_presets. Each predicate lives in htmlattr so the runtime spread leaf
// (gsx.AttrSinks) applies the identical test; adding a preset here means adding
// its gsx.URLPreset flag and codegen's name→flag mapping too.
//
// "htmx": the five htmx method attributes as URL rules, matched by EXACT name.
// A "hx-" prefix would be wrong — it would also classify hx-swap/hx-target/
// hx-trigger (and every other hx-* attribute), none of which carry URLs.
var presets = map[string]Rules{
"htmx": {URL: RuleSet{Names: []string{
"hx-get", "hx-post", "hx-put", "hx-delete", "hx-patch",
}}},
// "htmx": htmlattr.HTMXURL — the htmx request-URL attributes of htmx 2 and 4
// (hx-get/post/put/delete/patch, hx-query, hx-action) in every spelling htmx 4
// reads (plain, :inherited, :append, :inherited:append). A "hx-" prefix would
// be wrong — it would also classify hx-swap/hx-target/hx-trigger, none of
// which carry URLs.
var presets = map[string]func(lname string) bool{
"htmx": htmlattr.HTMXURL,
}

// presetURL reports whether an enabled preset classifies lname as a URL. An
// unknown name is a programming error — Rules.Valid rejects it at every config
// boundary — so it fails loudly rather than classifying nothing.
func (c *Classifier) presetURL(lname string) bool {
for _, p := range c.rules.Presets {
f, ok := presets[p]
if !ok {
panic(fmt.Sprintf("attrclass: unknown url preset %q", p))
}
if f(lname) {
return true
}
}
return false
}

// Preset returns the classification Rules contributed by the named preset and
// true, or the zero Rules and false when no preset by that name exists. Callers
// (gen config, corpus harness) surface an unknown name as a clear config error.
// Presets returns the enabled preset names in configuration order — the
// identity codegen ships to the spread leaf as gsx.URLPreset flags.
func (c *Classifier) Presets() []string {
if c == nil {
return nil
}
return c.rules.Presets
}

// Preset returns the Rules that enable the named preset and true, or the zero
// Rules and false when no preset by that name exists. Callers (gen config,
// corpus harness) surface an unknown name as a clear config error.
func Preset(name string) (Rules, bool) {
r, ok := presets[name]
return r, ok
if _, ok := presets[name]; !ok {
return Rules{}, false
}
return Rules{Presets: []string{name}}, true
}

// PresetNames returns the known preset names, sorted — for listing valid choices
Expand Down
47 changes: 37 additions & 10 deletions internal/attrclass/attrclass_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func TestFingerprintStable(t *testing.T) {
URL: RuleSet{Names: []string{"data-href"}},
URLTags: map[string]RuleSet{"img": {Names: []string{"data-src"}}},
},
"presets": {URL: RuleSet{Names: []string{"data-href"}}, Presets: []string{"htmx"}},
} {
if New(base).Fingerprint() == New(changed).Fingerprint() {
t.Errorf("changing %s must change the fingerprint", name)
Expand All @@ -93,33 +94,59 @@ func TestFingerprintStable(t *testing.T) {
}

func TestPreset(t *testing.T) {
// The "htmx" preset re-enables the five htmx method attrs as URL rules —
// the five EXACT names, never a "hx-" prefix (which would wrongly capture
// hx-swap/hx-target/hx-trigger, none of which are URLs).
// A preset is a named predicate compiled into htmlattr, not a name list:
// Preset returns its identity, which is what travels to the spread leaf
// and into the fingerprint. The htmx predicate covers the request-URL
// attributes of htmx 2 and htmx 4 in every spelling htmx 4 reads (see
// htmlattr.HTMXURL for the exhaustive pin).
rules, ok := Preset("htmx")
if !ok {
t.Fatal(`Preset("htmx") not found`)
}
want := Rules{URL: RuleSet{Names: []string{
"hx-get", "hx-post", "hx-put", "hx-delete", "hx-patch",
}}}
want := Rules{Presets: []string{"htmx"}}
if !reflect.DeepEqual(rules, want) {
t.Errorf(`Preset("htmx") = %+v, want %+v`, rules, want)
}

// A classifier built from the preset's rules classifies the method attrs as
// URL again, but leaves the non-URL hx-* attrs plain.
// A classifier built from the preset classifies every spelling of the
// request-URL attrs as URL, but leaves the non-URL hx-* attrs plain —
// including their own inherited spellings, and hx-method, which names the
// verb rather than a URL.
c := New(rules)
for _, n := range []string{"hx-get", "hx-post", "hx-put", "hx-delete", "hx-patch"} {
for _, n := range []string{"hx-get", "hx-post", "hx-put", "hx-delete", "hx-patch", "hx-query", "hx-action",
"hx-get:inherited", "hx-action:append", "hx-post:inherited:append", "HX-GET"} {
if got := c.Context("div", n); got != CtxURL {
t.Errorf("with htmx preset: Context(%q) = %v, want CtxURL", n, got)
}
}
for _, n := range []string{"hx-swap", "hx-target", "hx-trigger"} {
for _, n := range []string{"hx-swap", "hx-target", "hx-trigger", "hx-method", "hx-target:inherited", "hx-confirm:inherited:append"} {
if got := c.Context("div", n); got != CtxPlain {
t.Errorf("with htmx preset: Context(%q) = %v, want CtxPlain (not a URL attr)", n, got)
}
}
if got := c.Presets(); !reflect.DeepEqual(got, []string{"htmx"}) {
t.Errorf("Presets() = %v, want [htmx]", got)
}
if got := Builtin().Presets(); got != nil {
t.Errorf("Builtin().Presets() = %v, want nil", got)
}

// The preset is NOT expanded into the user URL rules codegen ships as
// data: the spread leaf gets the preset flag instead.
if got := c.UserURLRules("div"); !got.Empty() {
t.Errorf("UserURLRules(div) = %+v, want empty (preset travels as identity, not names)", got)
}

// Presets merge and dedupe like every other rule.
merged := Rules{URL: RuleSet{Names: []string{"data-href"}}}.Merge(rules).Merge(rules)
if want := (Rules{URL: RuleSet{Names: []string{"data-href"}}, Presets: []string{"htmx"}}); !reflect.DeepEqual(merged, want) {
t.Errorf("Merge = %+v, want %+v", merged, want)
}
// An unknown preset name in Rules is a validation error, so a hand-built
// Rules value cannot silently enable nothing.
if err := (Rules{Presets: []string{"nope"}}).Valid(); err == nil {
t.Error("Rules{Presets: [nope]}.Valid() = nil, want error")
}

// Unknown preset → (zero, false).
if got, ok := Preset("nope"); ok || !reflect.DeepEqual(got, Rules{}) {
Expand Down
Loading
Loading