diff --git a/API.md b/API.md index e564830..ef9ae9a 100644 --- a/API.md +++ b/API.md @@ -618,6 +618,49 @@ Remains `undefined` if there is no attribute value. Returns the attribute name qualified with the namespace if one is given. +### `attribute.insensitive` and `attribute.sensitive` + +The two case sensitivity flags defined by +[Selectors Level 4](https://www.w3.org/TR/selectors-4/#attribute-case): + +* `attribute.insensitive` is `true` for the case insensitive flag, `[href="foo" i]`. +* `attribute.sensitive` is `true` for the explicit case sensitive flag, `[href="foo" s]`. + +Both remain `undefined` if the attribute carries no flag at all. + +`attribute.insensitiveFlag` and `attribute.sensitiveFlag` are read-only and return +the canonical lowercase flag (`"i"` / `"s"`) when the corresponding boolean is set, +otherwise an empty string. + +```css +[href="foo"] /* insensitive: undefined, sensitive: undefined */ +[href="foo" i] /* insensitive: true, sensitive: false */ +[href="foo" s] /* insensitive: false, sensitive: true */ +``` + +A selector cannot request both case sensitivities at once, so the two properties +are mutually exclusive: setting either one to `true` clears the other, along with +any capitalized notation stored for it in +[`attribute.raws`](#attributeraws). + +```js +let attr = parser().astSync('[href="foo" I]').first.first; +attr.sensitive = true; +attr.insensitive; // false +attr.raws.insensitiveFlag; // undefined +attr.toString(); // '[href="foo" s]' +``` + +Because a flag can only ever appear in one place in the output, the two flags +share a single slot. The whitespace and comments around either flag live in +`attribute.spaces.insensitive` and `attribute.raws.spaces.insensitive`, and +`attribute.offsetOf()` reports the position of that slot under whichever of +`"insensitive"` or `"sensitive"` is actually present. + +Non-standard flags are not modeled as booleans. They are preserved verbatim in +`attribute.raws.insensitiveFlag` (see [`attribute.raws`](#attributeraws)) and are +left untouched by both setters. + ### `attribute.offsetOf(part)` Returns the offset of the attribute part specified relative to the @@ -637,6 +680,10 @@ Returns the attribute name qualified with the namespace if one is given. * `"operator"` - the match operator of the attribute * `"value"` - The value (string or identifier) * `"insensitive"` - the case insensitivity flag + * `"sensitive"` - the explicit case sensitivity flag + + The two case sensitivity flags share one slot in the output, so at most one of + `"insensitive"` and `"sensitive"` resolves to an offset; the other returns `-1`. ### `attribute.raws.unquoted` @@ -675,6 +722,18 @@ or `value` then a property is placed in the raws for that value containing the f If a comment is embedded within the space between parts of the attribute then the raw for that space is set accordingly. +The case sensitivity flags are stored here when the source used a notation that +does not round-trip from the boolean alone: + +* `attribute.raws.insensitiveFlag` holds `"I"` for `[href="foo" I]`. It also holds + any non-standard flag verbatim — `"y"` for `[href="foo" y]` — which is how such + a flag survives serialization even though neither `attribute.insensitive` nor + `attribute.sensitive` is `true`. +* `attribute.raws.sensitiveFlag` holds `"S"` for `[href="foo" S]`. + +The canonical lowercase forms are not stored, since `attribute.insensitive` and +`attribute.sensitive` already render them. + Setting an attribute's property `raws` value to be deleted. For now, changing the spaces required also updating or removing any of the diff --git a/postcss-selector-parser.d.ts b/postcss-selector-parser.d.ts index 9f0a233..ecae141 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 @@ -505,7 +517,7 @@ declare namespace parser { * @param part One of the possible values inside an attribute. * @returns -1 if the name is invalid or the value doesn't exist in this attribute. */ - offsetOf(part: "ns" | "namespace" | "attribute" | "attributeNS" | "operator" | "value" | "insensitive"): number; + offsetOf(part: "ns" | "namespace" | "attribute" | "attributeNS" | "operator" | "value" | "insensitive" | "sensitive"): number; } function attribute(opts: AttributeOptions): Attribute; function isAttribute(node: any): node is Attribute; diff --git a/src/__tests__/attributes.mjs b/src/__tests__/attributes.mjs index 47caf20..d18dcd5 100644 --- a/src/__tests__/attributes.mjs +++ b/src/__tests__/attributes.mjs @@ -487,6 +487,122 @@ 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); + // Both flags occupy the same slot, so only the one that is present resolves. + t.deepEqual(attr.offsetOf("sensitive"), 12); + t.deepEqual(attr.offsetOf("insensitive"), -1); + t.deepEqual(tree.toString(), '[href="foo" s]'); +}); + +test("case insensitive attribute selector offset", '[href="foo" i]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.offsetOf("insensitive"), 12); + t.deepEqual(attr.offsetOf("sensitive"), -1); +}); + +test("case sensitivity flags are mutually exclusive (i to s)", '[href="foo" i]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.insensitive, true); + + attr.sensitive = true; + + // `[href="foo" i s]` is not valid CSS, so adopting one flag drops the other. + t.deepEqual(attr.sensitive, true); + t.deepEqual(attr.insensitive, false); + t.deepEqual(attr.sensitiveFlag, "s"); + t.deepEqual(attr.insensitiveFlag, ""); + t.deepEqual(tree.toString(), '[href="foo" s]'); +}); + +test("case sensitivity flags are mutually exclusive (s to i)", '[href="foo" s]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.sensitive, true); + + attr.insensitive = true; + + t.deepEqual(attr.insensitive, true); + t.deepEqual(attr.sensitive, false); + t.deepEqual(attr.insensitiveFlag, "i"); + t.deepEqual(attr.sensitiveFlag, ""); + t.deepEqual(tree.toString(), '[href="foo" i]'); +}); + +test( + "switching case sensitivity erases the original notation (I to s)", + '[href="foo" I]', + (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.raws.insensitiveFlag, "I"); + + attr.sensitive = true; + + t.is(attr.raws.insensitiveFlag, undefined); + t.deepEqual(tree.toString(), '[href="foo" s]'); + }, +); + +test( + "switching case sensitivity erases the original notation (S to i)", + '[href="foo" S]', + (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.raws.sensitiveFlag, "S"); + + attr.insensitive = true; + + t.is(attr.raws.sensitiveFlag, undefined); + t.deepEqual(tree.toString(), '[href="foo" i]'); + }, +); + +test("case sensitivity setters keep non standard modifiers", '[href="foo" y]', (t, tree) => { + let attr = tree.atPosition(1, 13); + t.deepEqual(attr.raws.insensitiveFlag, "y"); + + // Only the standard "i"/"I" notations are erased, so an unrecognised flag kept + // in raws survives the round trip rather than being silently destroyed. + attr.sensitive = true; + t.deepEqual(attr.raws.insensitiveFlag, "y"); + t.deepEqual(tree.toString(), '[href="foo" s]'); + + attr.sensitive = false; + t.deepEqual(attr.raws.insensitiveFlag, "y"); + t.deepEqual(tree.toString(), '[href="foo" y]'); +}); + +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..1ab5e2f 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; } @@ -257,22 +261,58 @@ export default class Attribute extends Namespace { * If the case insensitive flag changes, the raw (escaped) value at `attr.raws.insensitiveFlag` * of the attribute is updated accordingly. * + * The two case sensitivity flags are mutually exclusive, so setting this flag + * clears `attr.sensitive` and any `s`/`S` in `attr.raws.sensitiveFlag`. + * * @param {true | false} insensitive true if the attribute should match case-insensitively. */ set insensitive(insensitive) { - if (!insensitive) { - this._insensitive = false; - + if (insensitive) { + // `[attr=value i s]` is not a thing, so adopting the case-insensitive flag + // drops the explicit case-sensitive flag and its original notation. + this.sensitive = false; + } else if ( + this.raws && + (this.raws.insensitiveFlag === "I" || this.raws.insensitiveFlag === "i") + ) { // "i" and "I" can be used in "this.raws.insensitiveFlag" to store the original notation. // When setting `attr.insensitive = false` both should be erased to ensure correct serialization. - if (this.raws && (this.raws.insensitiveFlag === "I" || this.raws.insensitiveFlag === "i")) { - this.raws.insensitiveFlag = undefined; - } + this.raws.insensitiveFlag = undefined; } 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. + * + * The two case sensitivity flags are mutually exclusive, so setting this flag + * clears `attr.insensitive` and any `i`/`I` in `attr.raws.insensitiveFlag`. + * + * @param {true | false} sensitive true if the attribute should match case-sensitively. + */ + set sensitive(sensitive) { + if (sensitive) { + // `[attr=value s i]` is not a thing, so adopting the case-sensitive flag + // drops the case-insensitive flag and its original notation. Only the + // standard "i"/"I" notations are erased, so a non-standard flag kept in + // "this.raws.insensitiveFlag" survives and can still be round-tripped. + this.insensitive = false; + } else if (this.raws && (this.raws.sensitiveFlag === "S" || this.raws.sensitiveFlag === "s")) { + // "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. + 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 @@ -345,6 +385,7 @@ export default class Attribute extends Namespace { * * "operator" - the match operator of the attribute * * "value" - The value (string or identifier) * * "insensitive" - the case insensitivity flag; + * * "sensitive" - the explicit case sensitivity flag; * @param part One of the possible values inside an attribute. * @returns -1 if the name is invalid or the value doesn't exist in this attribute. */ @@ -387,11 +428,17 @@ export default class Attribute extends Namespace { count += value.length; count += valueSpaces.after.length; + // Both case sensitivity flags occupy the same slot after the value, so they + // share the "insensitive" spaces. Each name only resolves when its own flag + // is the one actually present. let insensitiveSpaces = this._spacesFor("insensitive"); count += insensitiveSpaces.before.length; if (name === "insensitive") { return this.insensitive ? count : -1; } + if (name === "sensitive") { + return this.sensitive ? count : -1; + } return -1; } @@ -403,8 +450,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 &&