Skip to content
Open
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
59 changes: 59 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion postcss-selector-parser.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ declare namespace parser {
attribute: string;
operator?: AttributeOperator;
insensitive?: boolean;
sensitive?: boolean;
quoteMark?: QuoteMark;
/** @deprecated Use quoteMark instead. */
quoted?: boolean;
Expand All @@ -404,6 +405,8 @@ declare namespace parser {
operator?: string;
value?: string;
insensitive?: string;
insensitiveFlag?: string;
sensitiveFlag?: string;
spaces?: {
attribute?: Partial<Spaces>;
operator?: Partial<Spaces>;
Expand All @@ -417,6 +420,7 @@ declare namespace parser {
attribute: string;
operator?: AttributeOperator;
insensitive?: boolean;
sensitive?: boolean;
quoteMark: QuoteMark;
quoted?: boolean;
spaces: {
Expand All @@ -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<Spaces>;
operator?: Partial<Spaces>;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/attributes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
66 changes: 59 additions & 7 deletions src/selectors/attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 &&
Expand Down
Loading