diff --git a/lib/attribute.js b/lib/attribute.js index 9dd3802..b59155c 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -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 @@ -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 } } @@ -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 + '`') } diff --git a/lib/test.js b/lib/test.js index 2a7cc0d..f858958 100644 --- a/lib/test.js +++ b/lib/test.js @@ -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' @@ -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)) ) } diff --git a/readme.md b/readme.md index b3c5d70..59b8277 100644 --- a/readme.md +++ b/readme.md @@ -269,6 +269,8 @@ type Space = 'html' | 'svg' * [x] `h1 + p` (combinator: next-sibling selector) * [x] `h1 ~ p` (combinator: subsequent sibling selector) * [x] `[attr]` (attribute existence) +* [x] `[attr… i]` (attribute case-insensitive) +* [x] `[attr… s]` (attribute case-sensitive) (useless, default) * [x] `[attr=value]` (attribute equality) * [x] `[attr~=value]` (attribute contains in space-separated list) * [x] `[attr|=value]` (attribute equality or prefix) @@ -306,13 +308,12 @@ type Space = 'html' | 'svg' ## Unsupported * [ ] † `||` (column combinator) -* [ ] ‡ `ns|E` (namespace type selector) -* [ ] ‡ `*|E` (any namespace type selector) -* [ ] ‡ `|E` (no namespace type selector) -* [ ] ‡ `[ns|attr]` (namespace attribute) -* [ ] ‡ `[*|attr]` (any namespace attribute) -* [ ] ‡ `[|attr]` (no namespace attribute) -* [ ] ‡ `[attr=value i]` (attribute case-insensitive) +* [ ] ¶ `ns|E` (namespace type selector) +* [ ] ¶ `*|E` (any namespace type selector) +* [ ] ¶ `|E` (no namespace type selector) +* [ ] ¶ `[ns|attr]` (namespace attribute) +* [ ] ¶ `[*|attr]` (any namespace attribute) +* [ ] ¶ `[|attr]` (no namespace attribute) * [ ] ‖ `:nth-child(n of S)` (functional pseudo-class, note: scoping to parents is not supported) * [ ] ‖ `:nth-last-child(n of S)` (functional pseudo-class, note: scoping to @@ -360,6 +361,7 @@ type Space = 'html' | 'svg' * ‡ — not supported by the underlying algorithm * § — not very interested in writing / including the code for this * ‖ — too new, the spec is still changing +* ¶ — requires whole CSS files, not just selectors, to make sense * `:any()` and `:matches()` are renamed to `:is()` in CSS. ## Types diff --git a/test/matches.js b/test/matches.js index 31df49f..8d0085b 100644 --- a/test/matches.js +++ b/test/matches.js @@ -1122,6 +1122,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]', h('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]', h('a')) + }, /Unknown attribute case sensitivity modifier/) + } + ) + + await t.test( + 'should match sensitively (default) with `s` (#1)', + async function () { + assert.ok(matches('[x=y s]', h('a', {x: 'y'}))) + } + ) + + await t.test( + 'should match sensitively (default) with `s` (#2)', + async function () { + assert.ok(!matches('[x=y s]', h('a', {x: 'Y'}))) + } + ) + + await t.test('should match insensitively with `i` (#1)', async function () { + assert.ok(matches('[x=y i]', h('a', {x: 'y'}))) + }) + + await t.test('should match insensitively with `i` (#2)', async function () { + assert.ok(matches('[x=y i]', h('a', {x: 'Y'}))) + }) + + await t.test('should match insensitively with `i` (#3)', async function () { + assert.ok(matches('[x=Y i]', h('a', {x: 'y'}))) + }) + + await t.test('should match insensitively with `i` (#4)', async function () { + assert.ok(matches('[x=Y i]', h('a', {x: 'Y'}))) + }) + }) + await t.test('pseudo-classes', async function (t) { await t.test(':is()', async function (t) { await t.test('should match if any matches (type)', async function () {