From 6166541f40ee5e4be2fd408d7b18cf38ce67e228 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Mon, 20 Jul 2026 13:02:33 -0400 Subject: [PATCH] feat: support case-sensitive attribute flag (s) per Selectors L4 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 #309 --- postcss-selector-parser.d.ts | 12 ++++++++++++ src/__tests__/attributes.mjs | 37 ++++++++++++++++++++++++++++++++++++ src/parser.js | 11 ++++++++++- src/selectors/attribute.js | 36 ++++++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/postcss-selector-parser.d.ts b/postcss-selector-parser.d.ts index 9f0a233..ca8bc1d 100644 --- a/postcss-selector-parser.d.ts +++ b/postcss-selector-parser.d.ts @@ -387,6 +387,7 @@ declare namespace parser { attribute: string; operator?: AttributeOperator; insensitive?: boolean; + sensitive?: boolean; quoteMark?: QuoteMark; /** @deprecated Use quoteMark instead. */ quoted?: boolean; @@ -404,6 +405,8 @@ declare namespace parser { operator?: string; value?: string; insensitive?: string; + insensitiveFlag?: string; + sensitiveFlag?: string; spaces?: { attribute?: Partial; operator?: Partial; @@ -417,6 +420,7 @@ declare namespace parser { attribute: string; operator?: AttributeOperator; insensitive?: boolean; + sensitive?: boolean; quoteMark: QuoteMark; quoted?: boolean; spaces: { @@ -435,6 +439,8 @@ declare namespace parser { /** The value of the attribute with quotes and escapes. */ value?: string; insensitive?: string; + insensitiveFlag?: string; + sensitiveFlag?: string; spaces?: { attribute?: Partial; operator?: Partial; @@ -453,6 +459,12 @@ declare namespace parser { */ readonly insensitiveFlag : 'i' | ''; + /** + * The explicit case sensitivity flag (Selectors Level 4 `s`) or an empty + * string depending on whether this attribute is explicitly case sensitive. + */ + readonly sensitiveFlag : 's' | ''; + /** * Returns the attribute's value quoted such that it would be legal to use * in the value of a css file. The original value's quotation setting diff --git a/src/__tests__/attributes.mjs b/src/__tests__/attributes.mjs index 47caf20..df8ea19 100644 --- a/src/__tests__/attributes.mjs +++ b/src/__tests__/attributes.mjs @@ -487,6 +487,43 @@ test("non standard modifiers", '[href="foo" y]', (t, tree) => { t.deepEqual(tree.toString(), '[href="foo" y]'); }); +// https://github.com/postcss/postcss-selector-parser/issues/309 +test("case sensitive attribute selector", '[href="foo" s]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.sensitive, true); + t.deepEqual(attr.sensitiveFlag, "s"); + t.deepEqual(attr.insensitive, false); + t.deepEqual(attr.insensitiveFlag, ""); + // The `s` flag must be modeled explicitly, not mis-filed into raws.insensitiveFlag. + t.is(attr.raws.insensitiveFlag, undefined); + t.deepEqual(tree.toString(), '[href="foo" s]'); +}); + +test("capitalized case sensitive attribute selector", '[href="foo" S]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.sensitive, true); + t.deepEqual(attr.sensitiveFlag, "s"); + t.deepEqual(attr.insensitive, false); + t.is(attr.raws.insensitiveFlag, undefined); + t.deepEqual(attr.raws.sensitiveFlag, "S"); + t.deepEqual(tree.toString(), '[href="foo" S]'); + + // Clearing the flag must erase the original "S" notation from raws. + attr.sensitive = false; + t.is(attr.raws.sensitiveFlag, undefined); + t.deepEqual(tree.toString(), '[href="foo" ]'); +}); + +test("case sensitive attribute selector (unquoted)", "[href=test s]", (t, tree) => { + let attr = tree.nodes[0].nodes[0]; + t.deepEqual(attr.value, "test"); + t.deepEqual(attr.sensitive, true); + + attr.sensitive = false; + + t.deepEqual(tree.toString(), "[href=test ]"); +}); + test("comment after insensitive(non space)", '[href="foo" i/**/]', (t, tree) => { // https://github.com/postcss/postcss-selector-parser/issues/150 let attr = tree.atPosition(1, 13); diff --git a/src/parser.js b/src/parser.js index 3b00836..60ab84e 100644 --- a/src/parser.js +++ b/src/parser.js @@ -319,12 +319,21 @@ export default class Parser { lastAdded = "value"; } else { let insensitive = content === "i" || content === "I"; + let sensitive = content === "s" || content === "S"; if ( (node.value || node.value === "") && (node.quoteMark || spaceAfterMeaningfulToken) ) { node.insensitive = insensitive; - if (!insensitive || content === "I") { + node.sensitive = sensitive; + if (sensitive) { + // "s" is the canonical case-sensitive flag; store the original + // notation in "raws.sensitiveFlag" only for the "S" variant. + if (content === "S") { + ensureObject(node, "raws"); + node.raws.sensitiveFlag = content; + } + } else if (!insensitive || content === "I") { ensureObject(node, "raws"); node.raws.insensitiveFlag = content; } diff --git a/src/selectors/attribute.js b/src/selectors/attribute.js index f97d994..8a0f200 100644 --- a/src/selectors/attribute.js +++ b/src/selectors/attribute.js @@ -244,6 +244,10 @@ export default class Attribute extends Namespace { return this.insensitive ? "i" : ""; } + get sensitiveFlag() { + return this.sensitive ? "s" : ""; + } + get value() { return this._value; } @@ -273,6 +277,31 @@ export default class Attribute extends Namespace { this._insensitive = insensitive; } + get sensitive() { + return this._sensitive; + } + + /** + * Set the explicit case sensitive flag (Selectors Level 4 `s`). + * If the case sensitive flag changes, the raw (escaped) value at `attr.raws.sensitiveFlag` + * of the attribute is updated accordingly. + * + * @param {true | false} sensitive true if the attribute should match case-sensitively. + */ + set sensitive(sensitive) { + if (!sensitive) { + this._sensitive = false; + + // "s" and "S" can be used in "this.raws.sensitiveFlag" to store the original notation. + // When setting `attr.sensitive = false` both should be erased to ensure correct serialization. + if (this.raws && (this.raws.sensitiveFlag === "S" || this.raws.sensitiveFlag === "s")) { + this.raws.sensitiveFlag = undefined; + } + } + + this._sensitive = sensitive; + } + /** * Before 3.0, the value had to be set to an escaped value including any wrapped * quote marks. In 3.0, the semantics of `Attribute.value` changed so that the value @@ -403,8 +432,13 @@ export default class Attribute extends Namespace { if (this.operator && (this.value || this.value === "")) { selector.push(this._stringFor("operator")); selector.push(this._stringFor("value")); + // The case-sensitivity flag occupies a single slot after the value. + // Prefer the explicit case-sensitive flag ("s"/"S") when present, + // otherwise fall back to the case-insensitive flag ("i"/"I") or any + // non-standard flag preserved in `raws.insensitiveFlag`. + let flagProperty = this.sensitive ? "sensitiveFlag" : "insensitiveFlag"; selector.push( - this._stringFor("insensitiveFlag", "insensitive", (attrValue, attrSpaces) => { + this._stringFor(flagProperty, "insensitive", (attrValue, attrSpaces) => { if ( attrValue.length > 0 && !this.quoted &&