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 Aug 7, 2023
1 parent 0c47af3 commit 56ae734
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 226 deletions.
295 changes: 78 additions & 217 deletions lib/attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,9 @@
*/

import {stringify as commas} from 'comma-separated-tokens'
import {ok as assert, unreachable} from 'devlop'
import {hasProperty} from 'hast-util-has-property'
import {ok as assert} from 'devlop'
import {find} from 'property-information'
import {stringify as spaces} from 'space-separated-tokens'
import {zwitch} from 'zwitch'

/** @type {(query: AstAttribute, element: Element, info: Info) => boolean} */
const handle = zwitch('operator', {
unknown: unknownOperator,
// @ts-expect-error: `exists` is fine.
invalid: exists,
handlers: {
'=': exact,
'$=': ends,
'*=': contains,
'^=': begins,
'|=': exactOrPrefix,
'~=': spaceSeparatedList
}
})
import * as spaces from 'space-separated-tokens'

/**
* @param {AstRule} query
Expand All @@ -41,14 +24,12 @@ const handle = zwitch('operator', {
* @returns {boolean}
* Whether `element` matches `query`.
*/
export function attribute(query, element, schema) {
export function attributes(query, element, schema) {
let index = -1

if (query.attributes) {
while (++index < query.attributes.length) {
const attribute = query.attributes[index]

if (!handle(attribute, element, find(schema, attribute.name))) {
if (!attribute(query.attributes[index], element, schema)) {
return false
}
}
Expand All @@ -58,225 +39,105 @@ export function attribute(query, element, schema) {
}

/**
* Check whether an attribute has a substring as its start.
*
* `[attr^=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @param {Schema} schema
* Schema of element.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function begins(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')

return Boolean(
hasProperty(element, info.property) &&
normalizeValue(element.properties[info.property], info).slice(
0,
query.value.value.length
) === query.value.value
)
}
function attribute(query, element, schema) {
const info = find(schema, query.name)
const propertyValue = element.properties[info.property]
let value = normalizeValue(propertyValue, info)

// Exists.
if (!query.value) {
return value !== undefined
}

/**
* Check whether an attribute contains a substring.
*
* `[attr*=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function contains(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')
let key = query.value.value

return Boolean(
hasProperty(element, info.property) &&
normalizeValue(element.properties[info.property], info).includes(
query.value.value
)
)
}
// Case-sensitivity.
if (query.caseSensitivityModifier === 'i') {
key = key.toLowerCase()

/**
* Check whether an attribute has a substring as its end.
*
* `[attr$=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function ends(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')
if (value) {
value = value.toLowerCase()
}
}

return Boolean(
hasProperty(element, info.property) &&
normalizeValue(element.properties[info.property], info).slice(
-query.value.value.length
) === query.value.value
)
}
if (value !== undefined) {
switch (query.operator) {
// Exact.
case '=': {
return key === value
}

/**
* Check whether an attribute has an exact value.
*
* `[attr=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function exact(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')
// Ends.
case '$=': {
return key === value.slice(-key.length)
}

return Boolean(
hasProperty(element, info.property) &&
normalizeValue(element.properties[info.property], info) ===
query.value.value
)
}
// Contains.
case '*=': {
return value.includes(key)
}

/**
* Check whether an attribute has a substring as either the exact value or a
* prefix.
*
* `[attr|=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function exactOrPrefix(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')
// Begins.
case '^=': {
return key === value.slice(0, key.length)
}

const value = normalizeValue(element.properties[info.property], info)
// Exact or prefix.
case '|=': {
return (
key === value ||
(key === value.slice(0, key.length) &&
value.charAt(key.length) === '-')
)
}

return Boolean(
hasProperty(element, info.property) &&
(value === query.value.value ||
(value.slice(0, query.value.value.length) === query.value.value &&
value.charAt(query.value.value.length) === '-'))
)
}
// Space-separated list.
case '~=': {
return (
// For all other values (including comma-separated lists), return whether this
// is an exact match.
key === value ||
// If this is a space-separated list, and the query is contained in it, return
// true.
spaces.parse(value).includes(key)
)
}
// Other values are not yet supported by CSS.
// No default
}
}

/**
* Check whether an attribute exists.
*
* `[attr]`
*
* @param {AstAttribute} _
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function exists(_, element, info) {
return hasProperty(element, info.property)
return false
}

/**
* Stringify a hast value back to its HTML form.
*
* @param {Properties[keyof Properties]} value
* hast property value.
* @param {Info} info
* Property info.
* @returns {string}
* Normalized value.
* @returns {string | undefined}
*/
function normalizeValue(value, info) {
if (typeof value === 'boolean') {
return info.attribute
}

if (Array.isArray(value)) {
return (info.commaSeparated ? commas : spaces)(value)
if (value === null || value === undefined) {
// Empty.
} else if (typeof value === 'boolean') {
if (value) {
return info.attribute
}
} else if (Array.isArray(value)) {
if (value.length > 0) {
return (info.commaSeparated ? commas : spaces.stringify)(value)
}
} else {
return String(value)
}

return String(value)
}

/**
* Check whether an attribute, interpreted as a space-separated list, contains
* a value.
*
* `[attr~=value]`
*
* @param {AstAttribute} query
* Query.
* @param {Element} element
* Element.
* @param {Info} info
* Property info.
* @returns {boolean}
* Whether `element` matches `query`.
*/
function spaceSeparatedList(query, element, info) {
assert(query.value, 'expected `value`')
assert(query.value.type === 'String', 'expected plain string')

const value = element.properties[info.property]

return (
// If this is a space-separated list, and the query is contained in it, return
// true.
(!info.commaSeparated &&
value &&
typeof value === 'object' &&
value.includes(query.value.value)) ||
// For all other values (including comma-separated lists), return whether this
// is an exact match.
(hasProperty(element, info.property) &&
normalizeValue(value, info) === query.value.value)
)
}

// Shouldn’t be called, Parser throws an error instead.
/**
* @param {unknown} query_
* Query.
* @returns {never}
* Nothing.
* @throws {Error}
* Error.
*/
/* c8 ignore next 5 */
function unknownOperator(query_) {
// Runtime guarantees `operator` exists.
const query = /** @type {AstAttribute} */ (query_)
unreachable('Unknown operator `' + query.operator + '`')
}
4 changes: 2 additions & 2 deletions lib/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @typedef {import('./index.js').State} State
*/

import {attribute} from './attribute.js'
import {attributes} from './attribute.js'
import {className} from './class-name.js'
import {id} from './id.js'
import {name} from './name.js'
Expand Down Expand Up @@ -38,7 +38,7 @@ export function test(query, element, index, parent, state) {
(!query.tag || name(query, element)) &&
(!query.classNames || className(query, element)) &&
(!query.ids || id(query, element)) &&
(!query.attributes || attribute(query, element, state.schema)) &&
(!query.attributes || attributes(query, element, state.schema)) &&
(!query.pseudoClasses || pseudo(query, element, index, parent, state))
)
}
Loading

0 comments on commit 56ae734

Please sign in to comment.