From 10758242df52b1ccbe069f6a807e6fa4c1cefe0c Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 6 Nov 2023 15:48:01 +0100 Subject: [PATCH 1/5] Update dev-dependencies --- package.json | 8 ++++---- test/matches.js | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7b693e4..14de040 100644 --- a/package.json +++ b/package.json @@ -56,18 +56,18 @@ "devDependencies": { "@types/node": "^20.0.0", "c8": "^8.0.0", - "prettier": "^2.0.0", + "prettier": "^3.0.0", "remark-cli": "^11.0.0", "remark-preset-wooorm": "^9.0.0", "type-coverage": "^2.0.0", "typescript": "^5.0.0", - "unist-builder": "^3.0.0", - "xo": "^0.54.0" + "unist-builder": "^4.0.0", + "xo": "^0.56.0" }, "scripts": { "prepack": "npm run build && npm run format", "build": "tsc --build --clean && tsc --build && type-coverage", - "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", + "format": "remark . -qfo && prettier . -w --log-level warn && xo --fix", "test-api": "node --conditions development test/index.js", "test-coverage": "c8 --100 --reporter lcov npm run test-api", "test": "npm run build && npm run format && npm run test-coverage" diff --git a/test/matches.js b/test/matches.js index e1afb72..69538ea 100644 --- a/test/matches.js +++ b/test/matches.js @@ -98,9 +98,12 @@ test('select.matches()', async function (t) { for (const pseudo of simplePseudos) { await t.test('should throw on `' + pseudo + '`', async function () { - assert.throws(function () { - matches(':' + pseudo, u('root', [])) - }, new RegExp('Error: Cannot use `:' + pseudo + '` without parent')) + assert.throws( + function () { + matches(':' + pseudo, u('root', [])) + }, + new RegExp('Error: Cannot use `:' + pseudo + '` without parent') + ) }) } From 5c37078323aef648f02840fb6ffd3e3c2f84c86e Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 6 Nov 2023 15:53:23 +0100 Subject: [PATCH 2/5] Refactor error for unsupported feature --- lib/pseudo.js | 10 ++++++---- test/select-all.js | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/pseudo.js b/lib/pseudo.js index e040b4b..f365185 100644 --- a/lib/pseudo.js +++ b/lib/pseudo.js @@ -6,7 +6,7 @@ * @typedef {import('./types.js').SelectState} SelectState */ -import {unreachable} from 'devlop' +import {ok as assert, unreachable} from 'devlop' import fauxEsmNthCheck from 'nth-check' import {zwitch} from 'zwitch' import {parent} from './util.js' @@ -409,10 +409,12 @@ function getCachedNthCheck(query) { if (!fn) { const value = query.argument + assert(value, 'expected `argument`') - /* c8 ignore next 3 -- never happens with our config */ - if (!value || value.type !== 'Formula') { - unreachable('`:nth` has a formula') + if (value.type !== 'Formula') { + throw new Error( + 'Expected `nth` formula, such as `even` or `2n+1` (`of` is not yet supported)' + ) } fn = nthCheck(value.a + 'n+' + value.b) diff --git a/test/select-all.js b/test/select-all.js index 70a53b3..a2a01c6 100644 --- a/test/select-all.js +++ b/test/select-all.js @@ -438,6 +438,15 @@ test('select.selectAll()', async function (t) { [u('b', 'Bravo'), u('b', 'Delta'), u('b', 'Foxtrot')] ) }) + + await t.test( + 'should throw on unsupported `of` syntax', + async function () { + assert.throws(function () { + selectAll(':nth-child(odd of a)', u('a')) + }, /Expected `nth` formula, such as `even` or `2n\+1` \(`of` is not yet supported\)/) + } + ) }) await t.test(':nth-last-child', async function (t) { From ad8a91deab7b923fbe5089c4a7e840386916f966 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 6 Nov 2023 16:37:17 +0100 Subject: [PATCH 3/5] Add support for case-sensitivity modifiers --- lib/attribute.js | 205 +++++++++++++---------------------------------- lib/test.js | 4 +- test/matches.js | 50 ++++++++++++ 3 files changed, 108 insertions(+), 151 deletions(-) diff --git a/lib/attribute.js b/lib/attribute.js index 49b5b1d..8979cf2 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -4,35 +4,25 @@ * @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 + } } } @@ -40,150 +30,67 @@ export function attribute(query, node) { } /** - * 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` instead of `Array`. - // 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 } diff --git a/lib/test.js b/lib/test.js index 065656d..53c6774 100644 --- a/lib/test.js +++ b/lib/test.js @@ -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' /** @@ -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)) ) } diff --git a/test/matches.js b/test/matches.js index 69538ea..4ab950d 100644 --- a/test/matches.js +++ b/test/matches.js @@ -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( From 6f16614ec945f132aecce1e7d9c4332a9f7ea36f Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 6 Nov 2023 16:55:07 +0100 Subject: [PATCH 4/5] Update `css-selector-parser` --- lib/attribute.js | 24 +----------------------- lib/pseudo.js | 42 +----------------------------------------- lib/test.js | 32 +++++++++++++++++++------------- package.json | 2 +- 4 files changed, 22 insertions(+), 78 deletions(-) diff --git a/lib/attribute.js b/lib/attribute.js index 8979cf2..c8fbe9c 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -7,28 +7,6 @@ import {ok as assert} from 'devlop' import {indexable} from './util.js' -/** - * @param {AstRule} query - * Query. - * @param {Node} node - * Node. - * @returns {boolean} - * Whether `node` matches `query`. - */ -export function attributes(query, node) { - let index = -1 - - if (query.attributes) { - while (++index < query.attributes.length) { - if (!attribute(query.attributes[index], node)) { - return false - } - } - } - - return true -} - /** * @param {AstAttribute} query * Query. @@ -38,7 +16,7 @@ export function attributes(query, node) { * Whether `node` matches `query`. */ -function attribute(query, node) { +export function attribute(query, node) { indexable(node) const value = node[query.name] diff --git a/lib/pseudo.js b/lib/pseudo.js index f365185..8b930bb 100644 --- a/lib/pseudo.js +++ b/lib/pseudo.js @@ -1,5 +1,4 @@ /** - * @typedef {import('css-selector-parser').AstRule} AstRule * @typedef {import('css-selector-parser').AstPseudoClass} AstPseudoClass * @typedef {import('unist').Node} Node * @typedef {import('unist').Parent} Parent @@ -17,7 +16,7 @@ import {walk} from './walk.js' const nthCheck = fauxEsmNthCheck.default || fauxEsmNthCheck /** @type {(rule: AstPseudoClass, node: Node, index: number | undefined, parent: Parent | undefined, state: SelectState) => boolean} */ -const handle = zwitch('name', { +export const pseudo = zwitch('name', { // @ts-expect-error: always known. unknown: unknownPseudo, invalid: invalidPseudo, @@ -42,45 +41,6 @@ const handle = zwitch('name', { } }) -pseudo.needsIndex = [ - 'any', - 'first-child', - 'first-of-type', - 'last-child', - 'last-of-type', - 'is', - 'not', - 'nth-child', - 'nth-last-child', - 'nth-of-type', - 'nth-last-of-type', - 'only-child', - 'only-of-type' -] - -/** - * Check whether an node matches pseudo selectors. - * - * @param {AstRule} query - * @param {Node} node - * @param {number | undefined} index - * @param {Parent | undefined} parent - * @param {SelectState} state - * @returns {boolean} - */ -export function pseudo(query, node, index, parent, state) { - let offset = -1 - - if (query.pseudoClasses) { - while (++offset < query.pseudoClasses.length) { - if (!handle(query.pseudoClasses[offset], node, index, parent, state)) - return false - } - } - - return true -} - /** * Check whether a node matches an `:empty` pseudo. * diff --git a/lib/test.js b/lib/test.js index 53c6774..7e4ebc6 100644 --- a/lib/test.js +++ b/lib/test.js @@ -5,7 +5,7 @@ * @typedef {import('./types.js').SelectState} SelectState */ -import {attributes} from './attribute.js' +import {attribute} from './attribute.js' import {pseudo} from './pseudo.js' /** @@ -17,18 +17,24 @@ import {pseudo} from './pseudo.js' * @returns {boolean} */ export function test(query, node, index, parent, state) { - if (query.ids) throw new Error('Invalid selector: id') - if (query.classNames) throw new Error('Invalid selector: class') - if (query.pseudoElement) { - throw new Error('Invalid selector: `::' + query.pseudoElement + '`') + for (const item of query.items) { + // eslint-disable-next-line unicorn/prefer-switch + if (item.type === 'Attribute') { + if (!attribute(item, node)) return false + } else if (item.type === 'Id') { + throw new Error('Invalid selector: id') + } else if (item.type === 'ClassName') { + throw new Error('Invalid selector: class') + } else if (item.type === 'PseudoClass') { + if (!pseudo(item, node, index, parent, state)) return false + } else if (item.type === 'PseudoElement') { + throw new Error('Invalid selector: `::' + item.name + '`') + } else if (item.type === 'TagName') { + if (item.name !== node.type) return false + } else { + // Otherwise `item.type` is `WildcardTag`, which matches. + } } - return Boolean( - node && - (!query.tag || - query.tag.type === 'WildcardTag' || - query.tag.name === node.type) && - (!query.attributes || attributes(query, node)) && - (!query.pseudoClasses || pseudo(query, node, index, parent, state)) - ) + return true } diff --git a/package.json b/package.json index 14de040..ba3a874 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ ], "dependencies": { "@types/unist": "^3.0.0", - "css-selector-parser": "^2.0.0", + "css-selector-parser": "^3.0.0", "devlop": "^1.1.0", "nth-check": "^2.0.0", "zwitch": "^2.0.0" From 5d77ec4446a79da757345fb7e0874cb3ae9dba1e Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 6 Nov 2023 16:56:55 +0100 Subject: [PATCH 5/5] 5.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ba3a874..c409ab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unist-util-select", - "version": "5.0.0", + "version": "5.1.0", "description": "unist utility to select nodes with CSS-like selectors", "license": "MIT", "keywords": [