Skip to content

Commit ad8a91d

Browse files
committed
Add support for case-sensitivity modifiers
1 parent 5c37078 commit ad8a91d

File tree

3 files changed

+108
-151
lines changed

3 files changed

+108
-151
lines changed

lib/attribute.js

Lines changed: 56 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -4,186 +4,93 @@
44
* @typedef {import('./types.js').Node} Node
55
*/
66

7-
import {unreachable} from 'devlop'
8-
import {zwitch} from 'zwitch'
7+
import {ok as assert} from 'devlop'
98
import {indexable} from './util.js'
109

11-
/** @type {(query: AstAttribute, node: Node) => boolean} */
12-
const handle = zwitch('operator', {
13-
unknown: unknownOperator,
14-
// @ts-expect-error: hush.
15-
invalid: exists,
16-
handlers: {
17-
'=': exact,
18-
'^=': begins,
19-
'$=': ends,
20-
'*=': containsString,
21-
'~=': containsArray
22-
}
23-
})
24-
2510
/**
2611
* @param {AstRule} query
12+
* Query.
2713
* @param {Node} node
14+
* Node.
2815
* @returns {boolean}
16+
* Whether `node` matches `query`.
2917
*/
30-
export function attribute(query, node) {
18+
export function attributes(query, node) {
3119
let index = -1
3220

3321
if (query.attributes) {
3422
while (++index < query.attributes.length) {
35-
if (!handle(query.attributes[index], node)) return false
23+
if (!attribute(query.attributes[index], node)) {
24+
return false
25+
}
3626
}
3727
}
3828

3929
return true
4030
}
4131

4232
/**
43-
* Check whether an attribute exists.
44-
*
45-
* `[attr]`
46-
*
4733
* @param {AstAttribute} query
34+
* Query.
4835
* @param {Node} node
36+
* Node.
4937
* @returns {boolean}
38+
* Whether `node` matches `query`.
5039
*/
51-
function exists(query, node) {
52-
indexable(node)
53-
return node[query.name] !== null && node[query.name] !== undefined
54-
}
5540

56-
/**
57-
* Check whether an attribute has an exact value.
58-
*
59-
* `[attr=value]`
60-
*
61-
* @param {AstAttribute} query
62-
* @param {Node} node
63-
* @returns {boolean}
64-
*/
65-
function exact(query, node) {
66-
const queryValue = attributeValue(query)
67-
indexable(node)
68-
return exists(query, node) && String(node[query.name]) === queryValue
69-
}
70-
71-
/**
72-
* Check whether an attribute, as a list, contains a value.
73-
*
74-
* When the attribute value is not a list, checks that the serialized value
75-
* is the queried one.
76-
*
77-
* `[attr~=value]`
78-
*
79-
* @param {AstAttribute} query
80-
* @param {Node} node
81-
* @returns {boolean}
82-
*/
83-
function containsArray(query, node) {
41+
function attribute(query, node) {
8442
indexable(node)
8543
const value = node[query.name]
8644

87-
if (value === null || value === undefined) return false
88-
89-
const queryValue = attributeValue(query)
90-
91-
// If this is an array, and the query is contained in it, return `true`.
92-
// Coverage comment in place because TS turns `Array.isArray(unknown)`
93-
// into `Array<any>` instead of `Array<unknown>`.
94-
// type-coverage:ignore-next-line
95-
if (Array.isArray(value) && value.includes(queryValue)) {
96-
return true
45+
// Exists.
46+
if (!query.value) {
47+
return value !== null && value !== undefined
9748
}
9849

99-
// For all other values, return whether this is an exact match.
100-
return String(value) === queryValue
101-
}
50+
assert(query.value.type === 'String', 'expected plain string')
51+
let key = query.value.value
52+
let normal = value === null || value === undefined ? undefined : String(value)
10253

103-
/**
104-
* Check whether an attribute has a substring as its start.
105-
*
106-
* `[attr^=value]`
107-
*
108-
* @param {AstAttribute} query
109-
* @param {Node} node
110-
* @returns {boolean}
111-
*/
112-
function begins(query, node) {
113-
indexable(node)
114-
const value = node[query.name]
115-
const queryValue = attributeValue(query)
54+
// Case-sensitivity.
55+
if (query.caseSensitivityModifier === 'i') {
56+
key = key.toLowerCase()
11657

117-
return Boolean(
118-
query.value &&
119-
typeof value === 'string' &&
120-
value.slice(0, queryValue.length) === queryValue
121-
)
122-
}
123-
124-
/**
125-
* Check whether an attribute has a substring as its end.
126-
*
127-
* `[attr$=value]`
128-
*
129-
* @param {AstAttribute} query
130-
* @param {Node} node
131-
* @returns {boolean}
132-
*/
133-
function ends(query, node) {
134-
indexable(node)
135-
const value = node[query.name]
136-
const queryValue = attributeValue(query)
137-
138-
return Boolean(
139-
query.value &&
140-
typeof value === 'string' &&
141-
value.slice(-queryValue.length) === queryValue
142-
)
143-
}
144-
145-
/**
146-
* Check whether an attribute contains a substring.
147-
*
148-
* `[attr*=value]`
149-
*
150-
* @param {AstAttribute} query
151-
* @param {Node} node
152-
* @returns {boolean}
153-
*/
154-
function containsString(query, node) {
155-
indexable(node)
156-
const value = node[query.name]
157-
const queryValue = attributeValue(query)
158-
159-
return Boolean(
160-
typeof value === 'string' && queryValue && value.includes(queryValue)
161-
)
162-
}
163-
164-
// Shouldn’t be called, parser throws an error instead.
165-
/**
166-
* @param {unknown} query
167-
* @returns {never}
168-
*/
169-
/* c8 ignore next 4 */
170-
function unknownOperator(query) {
171-
// @ts-expect-error: `operator` guaranteed.
172-
throw new Error('Unknown operator `' + query.operator + '`')
173-
}
174-
175-
/**
176-
* @param {AstAttribute} query
177-
* @returns {string}
178-
*/
179-
function attributeValue(query) {
180-
const queryValue = query.value
58+
if (normal) {
59+
normal = normal.toLowerCase()
60+
}
61+
}
18162

182-
/* c8 ignore next 4 -- never happens with our config */
183-
if (!queryValue) unreachable('Attribute values should be defined')
184-
if (queryValue.type === 'Substitution') {
185-
unreachable('Substitutions are not enabled')
63+
if (value !== undefined) {
64+
switch (query.operator) {
65+
// Exact.
66+
case '=': {
67+
return typeof normal === 'string' && key === normal
68+
}
69+
70+
// Ends.
71+
case '$=': {
72+
return typeof value === 'string' && value.slice(-key.length) === key
73+
}
74+
75+
// Contains.
76+
case '*=': {
77+
return typeof value === 'string' && value.includes(key)
78+
}
79+
80+
// Begins.
81+
case '^=': {
82+
return typeof value === 'string' && key === value.slice(0, key.length)
83+
}
84+
85+
// Space-separated list.
86+
case '~=': {
87+
// type-coverage:ignore-next-line -- some bug with TS.
88+
return (Array.isArray(value) && value.includes(key)) || normal === key
89+
}
90+
// Other values are not yet supported by CSS.
91+
// No default
92+
}
18693
}
18794

188-
return queryValue.value
95+
return false
18996
}

lib/test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @typedef {import('./types.js').SelectState} SelectState
66
*/
77

8-
import {attribute} from './attribute.js'
8+
import {attributes} from './attribute.js'
99
import {pseudo} from './pseudo.js'
1010

1111
/**
@@ -28,7 +28,7 @@ export function test(query, node, index, parent, state) {
2828
(!query.tag ||
2929
query.tag.type === 'WildcardTag' ||
3030
query.tag.name === node.type) &&
31-
(!query.attributes || attribute(query, node)) &&
31+
(!query.attributes || attributes(query, node)) &&
3232
(!query.pseudoClasses || pseudo(query, node, index, parent, state))
3333
)
3434
}

test/matches.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,56 @@ test('select.matches()', async function (t) {
639639
}
640640
)
641641

642+
await t.test('attributes, case modifiers `[attr i]`', async function (t) {
643+
await t.test(
644+
'should throw when using a modifier in a wrong place',
645+
async function () {
646+
assert.throws(function () {
647+
matches('[x y]', u('a'))
648+
}, /Expected a valid attribute selector operator/)
649+
}
650+
)
651+
652+
await t.test(
653+
'should throw when using an unknown modifier',
654+
async function () {
655+
assert.throws(function () {
656+
matches('[x=y z]', u('a'))
657+
}, /Unknown attribute case sensitivity modifier/)
658+
}
659+
)
660+
661+
await t.test(
662+
'should match sensitively (default) with `s` (#1)',
663+
async function () {
664+
assert.ok(matches('[x=y s]', u('a', {x: 'y'})))
665+
}
666+
)
667+
668+
await t.test(
669+
'should match sensitively (default) with `s` (#2)',
670+
async function () {
671+
assert.ok(!matches('[x=y s]', u('a', {x: 'Y'})))
672+
}
673+
)
674+
675+
await t.test('should match insensitively with `i` (#1)', async function () {
676+
assert.ok(matches('[x=y i]', u('a', {x: 'y'})))
677+
})
678+
679+
await t.test('should match insensitively with `i` (#2)', async function () {
680+
assert.ok(matches('[x=y i]', u('a', {x: 'Y'})))
681+
})
682+
683+
await t.test('should match insensitively with `i` (#3)', async function () {
684+
assert.ok(matches('[x=Y i]', u('a', {x: 'y'})))
685+
})
686+
687+
await t.test('should match insensitively with `i` (#4)', async function () {
688+
assert.ok(matches('[x=Y i]', u('a', {x: 'Y'})))
689+
})
690+
})
691+
642692
await t.test('pseudo-classes', async function (t) {
643693
await t.test(':is', async function (t) {
644694
await t.test(

0 commit comments

Comments
 (0)