Skip to content

feat: support case-sensitive attribute flag (s) per Selectors L4 - #327

Open
abhu85 wants to merge 2 commits into
postcss:mainfrom
abhu85:fix/attribute-case-sensitive-flag
Open

feat: support case-sensitive attribute flag (s) per Selectors L4#327
abhu85 wants to merge 2 commits into
postcss:mainfrom
abhu85:fix/attribute-case-sensitive-flag

Conversation

@abhu85

@abhu85 abhu85 commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Adds support for the CSS Selectors Level 4 case-sensitive attribute flag s/S (e.g. [a=b s]), modeled analogously to the existing case-insensitive i/I flag.

Fixes #309

Problem

The parser only recognized i/I. For [class="foo" s] it left insensitive: false and dumped the s into raws.insensitiveFlag, so the explicit case-sensitive flag was never modeled on the AST. (It happened to round-trip via that mis-filed raw, but the parsed node did not reflect the flag.)

Solution

  • Recognize s/S in the attribute parser and set a new sensitive boolean on the node.
  • Add Attribute#sensitive (get/set) and Attribute#sensitiveFlag ('s' | ''), preserving the original S notation in raws.sensitiveFlag.
  • Treat the two case sensitivity flags as mutually exclusive: setting either boolean clears the other along with any capitalized notation held for it in raws. Only the standard i/I and s/S notations are erased, so a non-standard flag preserved in raws.insensitiveFlag ([a=b y]) still round-trips.
  • Serialize the flag from sensitiveFlag when present; i/I and non-standard flags (via raws.insensitiveFlag) are unchanged.
  • The flag reuses the existing insensitive space slot, so spaces.insensitive and raws.spaces.insensitive carry the whitespace and comments around either flag. offsetOf() additionally accepts "sensitive" — it gates on the boolean, so it returned -1 for an s flag on main, leaving that flag with no reachable offset.
  • Document both flags in API.md: the booleans, their mutual exclusivity, the shared space slot, the offsetOf behaviour, and what each raws flag holds.
  • Update the TypeScript declarations.

Test Plan

Parse + .toString() round-trip tests for [href="foo" s], [href="foo" S] and unquoted [href=test s], asserting sensitive/sensitiveFlag are set and that s is no longer mis-filed into raws.insensitiveFlag. Plus is and IS mutual-exclusion round trips, offsetOf for both flags, and a test pinning that a non-standard y flag survives both setters. Full suite: 795 passing; lint, typecheck and coverage gates green.

Compatibility

Not purely additive, so this wants a minor release rather than a patch. [a=b s] previously parked the flag in raws.insensitiveFlag with insensitive: false; it now sets sensitive: true and leaves raws untouched, so the AST changes for input that already parses today.

Serialization is byte-identical for s, S, i, I and non-standard flags, and no property is removed or repurposed, so the practical impact should be limited to code that read raws.insensitiveFlag to detect an unrecognized flag — which is the bug in #309.

The only other break I can find is type-level: readonly sensitiveFlag: 's' | '' is a required member added to the exported Attribute interface, so it affects code that implements Attribute rather than consuming it (insensitiveFlag was already required there).

Happy to adjust the API shape if you'd prefer a different modeling.

Attribute selectors accept the explicit case-sensitive flag `s`/`S`
(CSS Selectors Level 4), but the parser only modeled the
case-insensitive flag `i`/`I`. For `[a=b s]` the `s` was dumped into
`raws.insensitiveFlag` and left `insensitive` false, so the flag was
not modeled at all.

Model `s` analogously to `i`: add a `sensitive` boolean and a
`sensitiveFlag` getter on Attribute, recognize `s`/`S` in the parser,
and serialize the flag from `sensitiveFlag` when present. The original
`S` notation is preserved in `raws.sensitiveFlag`. Non-standard flags
still round-trip through `raws.insensitiveFlag` unchanged.

Fixes postcss#309
@MoOx

MoOx commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this — the s flag is standard (Selectors L4) and #309 has been open for a while, so I want it in.

The modeling mirrors insensitive closely, which is what I'd want.

Two things before I merge.

1. The two flags aren't mutually exclusive

const attr = parser().astSync('[a=b i]').first.first;
attr.sensitive = true;
attr.insensitive; // => true
attr.sensitive;   // => true
attr.toString();  // => '[a=b s]'

A node can end up claiming both case sensitivities at once, and the serializer silently picks s. Since [a=b i s] isn't a thing, could each setter clear the other? I.e. set sensitive(v) clears _insensitive and raws.insensitiveFlag when v is true, and symmetrically in set insensitive. A test round-tripping both directions would be good.

While you're in there: set sensitive assigns this._sensitive twice — the if (!sensitive) branch sets it to false, then the assignment at the end overwrites it. Only the raws cleanup needs to be conditional.

2. API.md isn't updated

sensitive, sensitiveFlag and raws.sensitiveFlag aren't documented. Worth stating explicitly that the flag shares the insensitive slot in attribute.spaces and offsetOf() — that's a sensible implementation choice, but it isn't guessable from the property names.

Minor, not a blocker

For the invalid input [a=b s i], main serializes [a=b s ] and this branch gives [a=b i ]. Both are wrong (there's a pre-existing stray space in there too) and the input isn't valid CSS, so I'm not asking you to handle it — just noting that it changed.

On the release

The description says the change is "fully additive and backward compatible", but that isn't quite accurate: [a=b s] used to park the flag in raws.insensitiveFlag and no longer does, so the AST changes for input that already parses today. Serialization is byte-identical, so I doubt anyone notices in practice — but I'd rather ship this as a minor (7.2.0) than a patch.

Does that match how you see it, or do you know of a consumer-visible break that would argue for a major?

Address review feedback on postcss#327:

- `attr.insensitive` and `attr.sensitive` now clear each other, along with
  any capitalized notation held in raws, so a node can no longer claim both
  case sensitivities at once. This also fixes the pre-existing case where an
  `s` attribute could not be switched to `i` at all, because the stale
  `raws.insensitiveFlag` won during serialization.
- Only the standard `i`/`I` and `s`/`S` notations are erased, so a
  non-standard flag preserved in `raws.insensitiveFlag` (e.g. `[a=b y]`)
  still round-trips.
- Drop the redundant double assignment in both setters.
- `offsetOf()` now accepts "sensitive". It previously returned -1 for an `s`
  flag, leaving that flag with no reachable offset.
- Document both flags, their mutual exclusivity, the shared spaces slot and
  `raws.sensitiveFlag` in API.md.
@abhu85

abhu85 commented Aug 7, 2026

Copy link
Copy Markdown
Author

Thanks — both points were real, and the first turned out to be worse than the repro showed. Pushed as 3d7faf3.

1. Mutual exclusivity

Fixed in both directions: each setter now clears the other flag along with any capitalized notation held for it in raws.

const attr = parser().astSync('[a=b i]').first.first;
attr.sensitive = true;
attr.insensitive; // => false
attr.toString();  // => '[a=b s]'

Worth flagging that the reverse direction was already broken on main, independently of this PR:

// main
const attr = parser().astSync('[a=b s]').first.first;
attr.insensitive = true;
attr.toString(); // => '[a=b s]'   ← the stale raws.insensitiveFlag wins

So there was no way to turn an s attribute into an i one through the public API at all. The cross-clearing fixes that too. Tests cover is and IS round trips in both directions.

One thing I deliberately did not do: the setters only erase the standard notations (i/I, s/S). A non-standard flag is preserved verbatim in raws.insensitiveFlag with both booleans false — your [href="foo" y] test — so an unconditional "clear the other" would let attr.sensitive = false silently delete it. There's now a test pinning that y survives both setters in both directions.

Good catch on the double assignment. It's gone, and since it was copied from set insensitive on main I fixed it there too rather than leave the two out of sync. Both setters now delegate the cross-clear (this.sensitive = false / this.insensitive = false) instead of duplicating the raws logic — no recursion, because setting either flag to false never delegates.

2. API.md

Added. One correction on the framing, though: the flag does not share the insensitive slot in offsetOf(). It shares the spaces slot — toString() and offsetOf() both read spaces.insensitive — but offsetOf gates on the boolean:

if (name === "insensitive") {
  return this.insensitive ? count : -1;
}

so offsetOf("insensitive") returns -1 for [a=b s], on this branch and on main. Documenting it as sharing that slot would have documented something that isn't true. Since offsetOf exists to point error messages at a specific part of the attribute, an s flag with no reachable offset seemed worth closing now that the flag is actually modeled — offsetOf accepts "sensitive", and the d.ts overload is widened to match. At most one of the two names resolves; the other returns -1. Happy to drop that and just document the gap instead if you'd rather keep this PR narrower.

Since insensitive / insensitiveFlag / raws.insensitiveFlag weren't documented either, I wrote one section covering both flags rather than only the new half: the booleans and their undefined-when-absent state, the mutual exclusivity, the shared spaces.insensitive / raws.spaces.insensitive slot, the offsetOf behaviour, and what each raws flag holds — including how non-standard flags ride along in raws.insensitiveFlag.

[a=b s i]

Left alone, as you suggested. Still invalid, still wrong, now wrong in a different way ([a=b i ] rather than [a=b s ]), and the stray space predates this PR.

On the release

You're right and the description was wrong — I've corrected it. 7.2.0 works for me.

On a consumer-visible break that would argue for major: I can only find a type-level one, and I don't think it qualifies. readonly sensitiveFlag: 's' | '' is a required member added to the exported Attribute interface, so it breaks code that implements Attribute (hand-rolled fixtures and mocks) but not code that reads it — and insensitiveFlag was already required there, so anything in that position was already coupled to this shape. At runtime, s / S / i / I / non-standard flags all serialize byte-identically to before; the only observable change is the one you identified, that the s flag is now modeled where it previously sat in the wrong raws field.

npm test is green: 795 tests, lint, typecheck and coverage thresholds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attribute selector's case-sensitivity identifier s

2 participants