Skip to content

Commit

Permalink
Add support for case-sensitivity modifiers
Browse files Browse the repository at this point in the history
  • Loading branch information
wooorm committed Nov 6, 2023
1 parent 5c37078 commit ad8a91d
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 151 deletions.
205 changes: 56 additions & 149 deletions lib/attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,186 +4,93 @@
* @typedef {import('./types.js').Node} Node
*/

import {unreachable} from 'devlop'
import {zwitch} from 'zwitch'
import {ok as assert} from 'devlop'
import {indexable} from './util.js'

/** @type {(query: AstAttribute, node: Node) => boolean} */
const handle = zwitch('operator', {
unknown: unknownOperator,
// @ts-expect-error: hush.
invalid: exists,
handlers: {
'=': exact,
'^=': begins,
'$=': ends,
'*=': containsString,
'~=': containsArray
}
})

/**
* @param {AstRule} query
* Query.
* @param {Node} node
* Node.
* @returns {boolean}
* Whether `node` matches `query`.
*/
export function attribute(query, node) {
export function attributes(query, node) {
let index = -1

if (query.attributes) {
while (++index < query.attributes.length) {
if (!handle(query.attributes[index], node)) return false
if (!attribute(query.attributes[index], node)) {
return false
}
}
}

return true
}

/**
* Check whether an attribute exists.
*
* `[attr]`
*
* @param {AstAttribute} query
* Query.
* @param {Node} node
* Node.
* @returns {boolean}
* Whether `node` matches `query`.
*/
function exists(query, node) {
indexable(node)
return node[query.name] !== null && node[query.name] !== undefined
}

/**
* Check whether an attribute has an exact value.
*
* `[attr=value]`
*
* @param {AstAttribute} query
* @param {Node} node
* @returns {boolean}
*/
function exact(query, node) {
const queryValue = attributeValue(query)
indexable(node)
return exists(query, node) && String(node[query.name]) === queryValue
}

/**
* Check whether an attribute, as a list, contains a value.
*
* When the attribute value is not a list, checks that the serialized value
* is the queried one.
*
* `[attr~=value]`
*
* @param {AstAttribute} query
* @param {Node} node
* @returns {boolean}
*/
function containsArray(query, node) {
function attribute(query, node) {
indexable(node)
const value = node[query.name]

if (value === null || value === undefined) return false

const queryValue = attributeValue(query)

// If this is an array, and the query is contained in it, return `true`.
// Coverage comment in place because TS turns `Array.isArray(unknown)`
// into `Array<any>` instead of `Array<unknown>`.
// type-coverage:ignore-next-line
if (Array.isArray(value) && value.includes(queryValue)) {
return true
// Exists.
if (!query.value) {
return value !== null && value !== undefined
}

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

/**
* Check whether an attribute has a substring as its start.
*
* `[attr^=value]`
*
* @param {AstAttribute} query
* @param {Node} node
* @returns {boolean}
*/
function begins(query, node) {
indexable(node)
const value = node[query.name]
const queryValue = attributeValue(query)
// Case-sensitivity.
if (query.caseSensitivityModifier === 'i') {
key = key.toLowerCase()

return Boolean(
query.value &&
typeof value === 'string' &&
value.slice(0, queryValue.length) === queryValue
)
}

/**
* Check whether an attribute has a substring as its end.
*
* `[attr$=value]`
*
* @param {AstAttribute} query
* @param {Node} node
* @returns {boolean}
*/
function ends(query, node) {
indexable(node)
const value = node[query.name]
const queryValue = attributeValue(query)

return Boolean(
query.value &&
typeof value === 'string' &&
value.slice(-queryValue.length) === queryValue
)
}

/**
* Check whether an attribute contains a substring.
*
* `[attr*=value]`
*
* @param {AstAttribute} query
* @param {Node} node
* @returns {boolean}
*/
function containsString(query, node) {
indexable(node)
const value = node[query.name]
const queryValue = attributeValue(query)

return Boolean(
typeof value === 'string' && queryValue && value.includes(queryValue)
)
}

// Shouldn’t be called, parser throws an error instead.
/**
* @param {unknown} query
* @returns {never}
*/
/* c8 ignore next 4 */
function unknownOperator(query) {
// @ts-expect-error: `operator` guaranteed.
throw new Error('Unknown operator `' + query.operator + '`')
}

/**
* @param {AstAttribute} query
* @returns {string}
*/
function attributeValue(query) {
const queryValue = query.value
if (normal) {
normal = normal.toLowerCase()
}
}

/* c8 ignore next 4 -- never happens with our config */
if (!queryValue) unreachable('Attribute values should be defined')
if (queryValue.type === 'Substitution') {
unreachable('Substitutions are not enabled')
if (value !== undefined) {
switch (query.operator) {
// Exact.
case '=': {
return typeof normal === 'string' && key === normal
}

// Ends.
case '$=': {
return typeof value === 'string' && value.slice(-key.length) === key
}

// Contains.
case '*=': {
return typeof value === 'string' && value.includes(key)
}

// Begins.
case '^=': {
return typeof value === 'string' && key === value.slice(0, key.length)
}

// Space-separated list.
case '~=': {
// type-coverage:ignore-next-line -- some bug with TS.
return (Array.isArray(value) && value.includes(key)) || normal === key
}
// Other values are not yet supported by CSS.
// No default
}
}

return queryValue.value
return false
}
4 changes: 2 additions & 2 deletions lib/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @typedef {import('./types.js').SelectState} SelectState
*/

import {attribute} from './attribute.js'
import {attributes} from './attribute.js'
import {pseudo} from './pseudo.js'

/**
Expand All @@ -28,7 +28,7 @@ export function test(query, node, index, parent, state) {
(!query.tag ||
query.tag.type === 'WildcardTag' ||
query.tag.name === node.type) &&
(!query.attributes || attribute(query, node)) &&
(!query.attributes || attributes(query, node)) &&
(!query.pseudoClasses || pseudo(query, node, index, parent, state))
)
}
50 changes: 50 additions & 0 deletions test/matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,56 @@ test('select.matches()', async function (t) {
}
)

await t.test('attributes, case modifiers `[attr i]`', async function (t) {
await t.test(
'should throw when using a modifier in a wrong place',
async function () {
assert.throws(function () {
matches('[x y]', u('a'))
}, /Expected a valid attribute selector operator/)
}
)

await t.test(
'should throw when using an unknown modifier',
async function () {
assert.throws(function () {
matches('[x=y z]', u('a'))
}, /Unknown attribute case sensitivity modifier/)
}
)

await t.test(
'should match sensitively (default) with `s` (#1)',
async function () {
assert.ok(matches('[x=y s]', u('a', {x: 'y'})))
}
)

await t.test(
'should match sensitively (default) with `s` (#2)',
async function () {
assert.ok(!matches('[x=y s]', u('a', {x: 'Y'})))
}
)

await t.test('should match insensitively with `i` (#1)', async function () {
assert.ok(matches('[x=y i]', u('a', {x: 'y'})))
})

await t.test('should match insensitively with `i` (#2)', async function () {
assert.ok(matches('[x=y i]', u('a', {x: 'Y'})))
})

await t.test('should match insensitively with `i` (#3)', async function () {
assert.ok(matches('[x=Y i]', u('a', {x: 'y'})))
})

await t.test('should match insensitively with `i` (#4)', async function () {
assert.ok(matches('[x=Y i]', u('a', {x: 'Y'})))
})
})

await t.test('pseudo-classes', async function (t) {
await t.test(':is', async function (t) {
await t.test(
Expand Down

0 comments on commit ad8a91d

Please sign in to comment.