diff --git a/.gitignore b/.gitignore index 735f4af..c977c85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +*.d.ts *.log coverage/ node_modules/ diff --git a/index.js b/index.js index 076cec5..bc7ec2f 100644 --- a/index.js +++ b/index.js @@ -1,16 +1,40 @@ +/** + * @typedef {import('./lib/types.js').Element} Element + * @typedef {import('./lib/types.js').HastNode} HastNode + * @typedef {import('./lib/types.js').Space} Space + */ + import {any} from './lib/any.js' import {parse} from './lib/parse.js' +/** + * @param {string} selector + * @param {HastNode} [node] + * @param {Space} [space] + * @returns {boolean} + */ export function matches(selector, node, space) { return Boolean( any(parse(selector), node, {space, one: true, shallow: true})[0] ) } +/** + * @param {string} selector + * @param {HastNode} [node] + * @param {Space} [space] + * @returns {Element|null} + */ export function select(selector, node, space) { return any(parse(selector), node, {space, one: true})[0] || null } +/** + * @param {string} selector + * @param {HastNode} [node] + * @param {Space} [space] + * @returns {Array.} + */ export function selectAll(selector, node, space) { return any(parse(selector), node, {space}) } diff --git a/lib/any.js b/lib/any.js index 40a7024..1f71db8 100644 --- a/lib/any.js +++ b/lib/any.js @@ -1,3 +1,13 @@ +/** + * @typedef {import('hast').Element} Element + * @typedef {import('./types.js').Selectors} Selectors + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').RuleSet} RuleSet + * @typedef {import('./types.js').HastNode} HastNode + * @typedef {import('./types.js').SelectIterator} SelectIterator + * @typedef {import('./types.js').SelectState} SelectState + */ + import {html, svg} from 'property-information' import {zwitch} from 'zwitch' import {enterState} from './enter-state.js' @@ -11,27 +21,52 @@ var type = zwitch('type', { handlers: {selectors, ruleSet, rule} }) +/** + * @param {Selectors|RuleSet|Rule} query + * @param {HastNode} node + * @param {SelectState} state + * @returns {Array.} + */ export function any(query, node, state) { + // @ts-ignore zwitch types are off. return query && node ? type(query, node, state) : [] } +/** + * @param {Selectors} query + * @param {HastNode} node + * @param {SelectState} state + * @returns {Array.} + */ function selectors(query, node, state) { - var collect = collector(state.one) + var collector = new Collector(state.one) var index = -1 while (++index < query.selectors.length) { - collect(ruleSet(query.selectors[index], node, state)) + collector.collectAll(ruleSet(query.selectors[index], node, state)) } - return collect.result + return collector.result } +/** + * @param {RuleSet} query + * @param {HastNode} node + * @param {SelectState} state + * @returns {Array.} + */ function ruleSet(query, node, state) { return rule(query.rule, node, state) } +/** + * @param {Rule} query + * @param {HastNode} tree + * @param {SelectState} state + * @returns {Array.} + */ function rule(query, tree, state) { - var collect = collector(state.one) + var collector = new Collector(state.one) if (state.shallow && query.rule) { throw new Error('Expected selector without nesting') @@ -47,6 +82,7 @@ function rule(query, tree, state) { language: null, direction: 'ltr', editableOrEditingHost: false, + // @ts-ignore assume elements. scopeElements: tree.type === 'root' ? tree.children : [tree], iterator, one: state.one, @@ -54,8 +90,9 @@ function rule(query, tree, state) { }) ) - return collect.result + return collector.result + /** @type {SelectIterator} */ function iterator(query, node, index, parent, state) { var exit = enterState(state, node) @@ -63,7 +100,8 @@ function rule(query, tree, state) { if (query.rule) { nest(query.rule, node, index, parent, configure(query.rule, state)) } else { - collect(node) + // @ts-ignore `test` also asserts `node is Element` + collector.collect(node) state.found = true } } @@ -71,6 +109,12 @@ function rule(query, tree, state) { exit() } + /** + * @template {SelectState} S + * @param {Rule} query + * @param {S} state + * @returns {S} + */ function configure(query, state) { var pseudos = query.pseudos || [] var index = -1 @@ -87,7 +131,10 @@ function rule(query, tree, state) { } // Shouldn’t be called, all data is handled. -/* c8 ignore next 3 */ +/* c8 ignore next 6 */ +/** + * @param {{[x: string]: unknown, type: string}} query + */ function unknownType(query) { throw new Error('Unknown type `' + query.type + '`') } @@ -98,35 +145,45 @@ function invalidType() { throw new Error('Invalid type') } -function collector(one) { - var result = [] - var found - - collect.result = result - - return collect +class Collector { + /** + * @param {boolean} one + */ + constructor(one) { + /** @type {Array.} */ + this.result = [] + /** @type {boolean} */ + this.one = one + /** @type {boolean} */ + this.found = false + } - // Append elements to array, filtering out duplicates. - function collect(source) { + /** + * Append nodes to array, filtering out duplicates. + * + * @param {Array.} elements + */ + collectAll(elements) { var index = -1 - if ('length' in source) { - while (++index < source.length) { - collectOne(source[index]) - } - } else { - collectOne(source) + while (++index < elements.length) { + this.collect(elements[index]) } + } - function collectOne(element) { - if (one) { - // Shouldn’t happen, safeguards performance problems. - /* c8 ignore next */ - if (found) throw new Error('Cannot collect multiple nodes') - found = true - } - - if (!result.includes(element)) result.push(element) + /** + * Append one node. + * + * @param {Element} element + */ + collect(element) { + if (this.one) { + // Shouldn’t happen, safeguards performance problems. + /* c8 ignore next */ + if (this.found) throw new Error('Cannot collect multiple nodes') + this.found = true } + + if (!this.result.includes(element)) this.result.push(element) } } diff --git a/lib/attribute.js b/lib/attribute.js index 4f295a6..f49e85f 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -1,3 +1,12 @@ +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').RuleAttr} RuleAttr + * @typedef {import('./types.js').Element} Element + * @typedef {import('./types.js').Schema} Schema + * @typedef {import('./types.js').Info} Info + * @typedef {import('./types.js').PropertyValue} PropertyValue + */ + import {stringify as commas} from 'comma-separated-tokens' import {hasProperty} from 'hast-util-has-property' import {find} from 'property-information' @@ -17,33 +26,60 @@ var handle = zwitch('operator', { } }) -export function attribute(query, node, schema) { +/** + * @param {Rule} query + * @param {Element} element + * @param {Schema} schema + * @returns {boolean} + */ +export function attribute(query, element, schema) { var attrs = query.attrs var index = -1 while (++index < attrs.length) { - if (!handle(attrs[index], node, find(schema, attrs[index].name))) return + if (!handle(attrs[index], element, find(schema, attrs[index].name))) return } return true } -// `[attr]` -function exists(query, node, info) { - return hasProperty(node, info.property) +/** + * `[attr]` + * + * @param {RuleAttr} _ + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function exists(_, element, info) { + return hasProperty(element, info.property) } -// `[attr=value]` -function exact(query, node, info) { +/** + * `[attr=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function exact(query, element, info) { return ( - hasProperty(node, info.property) && - normalizeValue(node.properties[info.property], info) === query.value + hasProperty(element, info.property) && + normalizeValue(element.properties[info.property], info) === query.value ) } -// `[attr~=value]` -function spaceSeparatedList(query, node, info) { - var value = node.properties[info.property] +/** + * `[attr~=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function spaceSeparatedList(query, element, info) { + var value = element.properties[info.property] return ( // If this is a comma-separated list, and the query is contained in it, return @@ -54,71 +90,107 @@ function spaceSeparatedList(query, node, info) { value.includes(query.value)) || // For all other values (including comma-separated lists), return whether this // is an exact match. - (hasProperty(node, info.property) && + (hasProperty(element, info.property) && normalizeValue(value, info) === query.value) ) } -// `[attr|=value]` -function exactOrPrefix(query, node, info) { - var value = normalizeValue(node.properties[info.property], info) +/** + * `[attr|=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function exactOrPrefix(query, element, info) { + var value = normalizeValue(element.properties[info.property], info) return ( - hasProperty(node, info.property) && + hasProperty(element, info.property) && (value === query.value || (value.slice(0, query.value.length) === query.value && value.charAt(query.value.length) === '-')) ) } -// `[attr^=value]` -function begins(query, node, info) { +/** + * `[attr^=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function begins(query, element, info) { return ( - hasProperty(node, info.property) && - normalizeValue(node.properties[info.property], info).slice( + hasProperty(element, info.property) && + normalizeValue(element.properties[info.property], info).slice( 0, query.value.length ) === query.value ) } -// `[attr$=value]` -function ends(query, node, info) { +/** + * `[attr$=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function ends(query, element, info) { return ( - hasProperty(node, info.property) && - normalizeValue(node.properties[info.property], info).slice( + hasProperty(element, info.property) && + normalizeValue(element.properties[info.property], info).slice( -query.value.length ) === query.value ) } -// `[attr*=value]` -function contains(query, node, info) { +/** + * `[attr*=value]` + * + * @param {RuleAttr} query + * @param {Element} element + * @param {Info} info + * @returns {boolean} + */ +function contains(query, element, info) { return ( - hasProperty(node, info.property) && - normalizeValue(node.properties[info.property], info).includes(query.value) + hasProperty(element, info.property) && + normalizeValue(element.properties[info.property], info).includes( + query.value + ) ) } // Shouldn’t be called, Parser throws an error instead. +/** + * @param {RuleAttr} query + * @returns {boolean} + */ /* c8 ignore next 3 */ function unknownOperator(query) { throw new Error('Unknown operator `' + query.operator + '`') } -// Stringify a hast value back to its HTML form. +/** + * Stringify a hast value back to its HTML form. + * + * @param {PropertyValue} value + * @param {Info} info + * @returns {string} + */ function normalizeValue(value, info) { - if (typeof value === 'number') { - return String(value) - } - if (typeof value === 'boolean') { return info.attribute } - if (typeof value === 'object' && 'length' in value) { + if (Array.isArray(value)) { return (info.commaSeparated ? commas : spaces)(value) } - return value + return String(value) } diff --git a/lib/class-name.js b/lib/class-name.js index 79fface..16daf64 100644 --- a/lib/class-name.js +++ b/lib/class-name.js @@ -1,5 +1,17 @@ -export function className(query, node) { - var value = node.properties.className || [] +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').Element} Element + */ + +/** + * @param {Rule} query + * @param {Element} element + * @returns {boolean} + */ +export function className(query, element) { + /** @type {Array.} */ + // @ts-ignore Assume array. + var value = element.properties.className || [] var index = -1 while (++index < query.classNames.length) { diff --git a/lib/enter-state.js b/lib/enter-state.js index fc1ec8f..679abca 100644 --- a/lib/enter-state.js +++ b/lib/enter-state.js @@ -1,23 +1,44 @@ +/** + * @typedef {import('./types.js').SelectState} SelectState + * @typedef {import('./types.js').HastNode} HastNode + * @typedef {import('./types.js').ElementChild} ElementChild + * @typedef {import('./types.js').Direction} Direction + * @typedef {import('unist-util-visit').Visitor} Visitor + */ + import {direction} from 'direction' import {isElement} from 'hast-util-is-element' import toString from 'hast-util-to-string' import {svg} from 'property-information' import {visit, EXIT, SKIP} from 'unist-util-visit' +import {element} from './util.js' +/** + * @param {SelectState} state + * @param {HastNode} node + * @returns {() => void} + */ // eslint-disable-next-line complexity export function enterState(state, node) { var schema = state.schema var language = state.language var currentDirection = state.direction var editableOrEditingHost = state.editableOrEditingHost + /** @type {Direction|null} */ var dirInferred + /** @type {string} */ var type + /** @type {boolean} */ var found + /** @type {string} */ var lang + /** @type {Direction|null} */ var dir - if (node.type === 'element') { + if (element(node)) { + // @ts-ignore Assume string. lang = node.properties.xmlLang || node.properties.lang + // @ts-ignore Assume string. type = node.properties.type || 'text' dir = dirProperty(node) @@ -63,8 +84,10 @@ export function enterState(state, node) { type === 'text') ) { // Check value of ``. + // @ts-ignore something is `never` in types but this is needed. dirInferred = node.properties.value - ? dirBidi(node.properties.value) + ? // @ts-ignore Assume string + dirBidi(node.properties.value) : 'ltr' } else { // Check text nodes in `node`. @@ -93,6 +116,7 @@ export function enterState(state, node) { state.editableOrEditingHost = editableOrEditingHost } + /** @type {Visitor} */ function inferDirectionality(child) { if (child.type === 'text') { dirInferred = dirBidi(child.value) @@ -109,14 +133,22 @@ export function enterState(state, node) { } } +/** + * @param {string} value + * @returns {Direction} + */ function dirBidi(value) { var result = direction(value) return result === 'neutral' ? null : result } +/** + * @param {ElementChild} node + * @returns {Direction} + */ function dirProperty(node) { var value = - typeof node.properties.dir === 'string' + element(node) && typeof node.properties.dir === 'string' ? node.properties.dir.toLowerCase() : null return value === 'auto' || value === 'ltr' || value === 'rtl' ? value : null diff --git a/lib/id.js b/lib/id.js index 163c630..1b14632 100644 --- a/lib/id.js +++ b/lib/id.js @@ -1,3 +1,13 @@ -export function id(query, node) { - return node.properties.id === query.id +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').Element} Element + */ + +/** + * @param {Rule} query + * @param {Element} element + * @returns {boolean} + */ +export function id(query, element) { + return element.properties.id === query.id } diff --git a/lib/name.js b/lib/name.js index 7234c98..9c0347b 100644 --- a/lib/name.js +++ b/lib/name.js @@ -1,3 +1,13 @@ -export function name(query, node) { - return query.tagName === '*' || query.tagName === node.tagName +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').Element} Element + */ + +/** + * @param {Rule} query + * @param {Element} element + * @returns {boolean} + */ +export function name(query, element) { + return query.tagName === '*' || query.tagName === element.tagName } diff --git a/lib/nest.js b/lib/nest.js index 193ece0..b0c09d7 100644 --- a/lib/nest.js +++ b/lib/nest.js @@ -1,5 +1,16 @@ +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').Node} Node + * @typedef {import('./types.js').Element} Element + * @typedef {import('./types.js').Parent} Parent + * @typedef {import('./types.js').SelectState} SelectState + * @typedef {import('./types.js').SelectIterator} SelectIterator + * @typedef {import('./types.js').Handler} Handler + */ + import {zwitch} from 'zwitch' import {enterState} from './enter-state.js' +import {parent, element} from './util.js' var own = {}.hasOwnProperty @@ -9,21 +20,26 @@ var handle = zwitch('nestingOperator', { handlers: { null: descendant, // `null` is the descendant combinator. '>': child, - '+': nextSibling, - '~': subsequentSibling + '+': adjacentSibling, + '~': generalSibling } }) +/** @type {Handler} */ export function nest(query, node, index, parent, state) { return handle(query, node, index, parent, state) } // Shouldn’t be called, parser gives correct data. -/* c8 ignore next 3 */ +/* c8 ignore next 6 */ +/** + * @param {{[x: string]: unknown, type: string}} query + */ function unknownNesting(query) { throw new Error('Unexpected nesting `' + query.nestingOperator + '`') } +/** @type {Handler} */ function topScan(query, node, index, parent, state) { // Shouldn’t happen. /* c8 ignore next 3 */ @@ -31,56 +47,72 @@ function topScan(query, node, index, parent, state) { throw new Error('topScan is supposed to be called from the root node') } - state.iterator(...arguments) - - if (!state.shallow) descendant(...arguments) + state.iterator(query, node, index, parent, state) + if (!state.shallow) descendant(query, node, index, parent, state) } +/** @type {Handler} */ function descendant(query, node, index, parent, state) { var previous = state.iterator state.iterator = iterator + child(query, node, index, parent, state) - child(...arguments) - - function iterator(_, ...rest) { + /** @type {SelectIterator} */ + function iterator(query, node, index, parent, state) { state.iterator = previous - previous(...arguments) + previous(query, node, index, parent, state) state.iterator = iterator if (state.one && state.found) return - child(query, ...rest) + child(query, node, index, parent, state) } } -function child(query, node, index, parent, state) { - if (!node.children || node.children.length === 0) return +/** @type {Handler} */ +function child(query, node, _1, _2, state) { + if (!parent(node)) return + if (node.children.length === 0) return indexedSearch(query, node, state) } -function nextSibling(query, node, index, parent, state) { +/** @type {Handler} */ +function adjacentSibling(query, _, index, parent, state) { // Shouldn’t happen. /* c8 ignore next */ if (!parent) return indexedSearch(query, parent, state, index + 1, true) } -function subsequentSibling(query, node, index, parent, state) { +/** @type {Handler} */ +function generalSibling(query, _, index, parent, state) { // Shouldn’t happen. /* c8 ignore next */ if (!parent) return indexedSearch(query, parent, state, index + 1) } -// Handles `typeIndex` and `typeCount` properties for every walker. +/** + * Handles `typeIndex` and `typeCount` properties for every walker. + * + * @param {Rule} query + * @param {Parent} parent + * @param {SelectState} state + * @param {number} [from=0] + * @param {boolean} [firstElementOnly=false] + */ function indexedSearch(query, parent, state, from, firstElementOnly) { var handle = state.index ? delay : add var children = parent.children - var types = {} - var delayed = [] var elements = 0 var index = -1 + /** @type {Object.} */ + var types = {} + /** @type {Array.} */ + var delayed = [] + /** @type {Node} */ + var child // Start looking at `from` if (from === undefined || from === null) from = 0 @@ -91,17 +123,19 @@ function indexedSearch(query, parent, state, from, firstElementOnly) { // If we need to index for types, do so for all elements before `from`. if (state.index) { while (++index < from) { - if (children[index].type === 'element') count(children[index].tagName) + child = children[index] + if (element(child)) count(child.tagName) } } index = from - 1 while (++index < children.length) { + child = children[index] // Only check elements. // Check either all elements, or only check the first sibling - if (children[index].type === 'element') { - handle(children[index], index) + if (element(child)) { + handle(child, index) // Stop if we’re looking for one node and it’s already found. if (state.one && state.found) return @@ -118,6 +152,10 @@ function indexedSearch(query, parent, state, from, firstElementOnly) { } } + /** + * @param {Element} node + * @param {number} childIndex + */ function delay(node, childIndex) { var elementsBefore = elements var elementsByTypeBefore = own.call(types, node.tagName) @@ -141,12 +179,19 @@ function indexedSearch(query, parent, state, from, firstElementOnly) { } } + /** + * @param {Element} node + * @param {number} childIndex + */ function add(node, childIndex) { var exit = enterState(state, node) state.iterator(query, node, childIndex, parent, state) exit() } + /** + * @param {string} name + */ function count(name) { if (!own.call(types, name)) types[name] = 0 elements++ diff --git a/lib/parse.js b/lib/parse.js index 09c390c..2d3112f 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -1,7 +1,18 @@ +/** + * @typedef {import('./types.js').Selector} Selector + * @typedef {import('./types.js').Selectors} Selectors + * @typedef {import('./types.js').RuleSet} RuleSet + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').RulePseudo} RulePseudo + * @typedef {import('./types.js').RulePseudoNth} RulePseudoNth + */ + import {CssSelectorParser} from 'css-selector-parser' import fauxEsmNthCheck from 'nth-check' import {zwitch} from 'zwitch' +/** @type {import('nth-check').default} */ +// @ts-ignore var nthCheck = fauxEsmNthCheck.default var nth = new Set([ @@ -13,47 +24,64 @@ var nth = new Set([ var parser = new CssSelectorParser() -var compile = zwitch('type', { - handlers: {selectors, ruleSet, rule} -}) +var compile = zwitch('type', {handlers: {selectors, ruleSet, rule}}) parser.registerAttrEqualityMods('~', '|', '^', '$', '*') parser.registerSelectorPseudos('any', 'matches', 'not', 'has') parser.registerNestingOperators('>', '+', '~') +/** + * @param {string} selector + * @returns {Selector} + */ export function parse(selector) { if (typeof selector !== 'string') { throw new TypeError('Expected `string` as selector, not `' + selector + '`') } + // @ts-ignore types are wrong. return compile(parser.parse(selector)) } +/** + * @param {Selectors} query + * @returns {Selectors} + */ function selectors(query) { - var selectors = query.selectors var index = -1 - while (++index < selectors.length) { - compile(selectors[index]) + while (++index < query.selectors.length) { + compile(query.selectors[index]) } return query } +/** + * @param {RuleSet} query + * @returns {Rule} + */ function ruleSet(query) { return rule(query.rule) } +/** + * @param {Rule} query + * @returns {Rule} + */ function rule(query) { var pseudos = query.pseudos || [] var index = -1 + /** @type {RulePseudo|RulePseudoNth} */ var pseudo while (++index < pseudos.length) { pseudo = pseudos[index] if (nth.has(pseudo.name)) { + // @ts-ignore Patch a non-primitive type. pseudo.value = nthCheck(pseudo.value) + // @ts-ignore Patch a non-primitive type. pseudo.valueType = 'function' } } diff --git a/lib/pseudo.js b/lib/pseudo.js index 3ccc8fa..af360f6 100644 --- a/lib/pseudo.js +++ b/lib/pseudo.js @@ -1,9 +1,21 @@ +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').RulePseudo} RulePseudo + * @typedef {import('./types.js').RulePseudoNth} RulePseudoNth + * @typedef {import('./types.js').RulePseudoSelector} RulePseudoSelector + * @typedef {import('./types.js').Parent} Parent + * @typedef {import('./types.js').Selector} Selector + * @typedef {import('./types.js').Selectors} Selectors + * @typedef {import('./types.js').SelectState} SelectState + * @typedef {import('./types.js').Element} Element + * @typedef {import('./types.js').ElementChild} ElementChild + */ + import {extendedFilter} from 'bcp-47-match' import {parse as commas} from 'comma-separated-tokens' import {hasProperty} from 'hast-util-has-property' import {isElement} from 'hast-util-is-element' import {whitespace} from 'hast-util-whitespace' -import not from 'not' import {zwitch} from 'zwitch' import {any} from './any.js' @@ -18,7 +30,7 @@ var handle = zwitch('name', { dir, disabled, empty, - enabled: not(disabled), + enabled, 'first-child': firstChild, 'first-of-type': firstOfType, has, @@ -26,15 +38,15 @@ var handle = zwitch('name', { 'last-child': lastChild, 'last-of-type': lastOfType, matches, - not: not(matches), + not, 'nth-child': nthChild, 'nth-last-child': nthLastChild, 'nth-of-type': nthOfType, 'nth-last-of-type': nthLastOfType, 'only-child': onlyChild, 'only-of-type': onlyOfType, - optional: not(required), - 'read-only': not(readWrite), + optional, + 'read-only': readOnly, 'read-write': readWrite, required, root, @@ -55,26 +67,43 @@ pseudo.needsIndex = [ 'only-of-type' ] -export function pseudo(query, node, index, parent, state) { +/** + * @param {Rule} query + * @param {Element} element + * @param {number|null} index + * @param {Parent|null} parent + * @param {SelectState} state + * @returns {boolean} + */ +export function pseudo(query, element, index, parent, state) { var pseudos = query.pseudos var offset = -1 while (++offset < pseudos.length) { - if (!handle(pseudos[offset], node, index, parent, state)) return + if (!handle(pseudos[offset], element, index, parent, state)) return } return true } -function matches(query, node, index, parent, state) { +/** + * @param {RulePseudoSelector} query + * @param {Element} element + * @param {number|null} _1 + * @param {Parent|null} _2 + * @param {SelectState} state + * @returns {boolean} + */ +function matches(query, element, _1, _2, state) { var shallow = state.shallow var one = state.one + /** @type {boolean} */ var result state.shallow = true state.one = true - result = any(query.value, node, state)[0] === node + result = any(query.value, element, state)[0] === element state.shallow = shallow state.one = one @@ -82,29 +111,66 @@ function matches(query, node, index, parent, state) { return result } -function anyLink(query, node) { - return isElement(node, ['a', 'area', 'link']) && hasProperty(node, 'href') +/** + * @param {RulePseudoSelector} query + * @param {Element} element + * @param {number|null} index + * @param {Parent|null} parent + * @param {SelectState} state + * @returns {boolean} + */ +function not(query, element, index, parent, state) { + return !matches(query, element, index, parent, state) } -function checked(query, node) { - if (isElement(node, ['input', 'menuitem'])) { +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function anyLink(_, element) { + return ( + isElement(element, ['a', 'area', 'link']) && hasProperty(element, 'href') + ) +} + +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function checked(_, element) { + if (isElement(element, ['input', 'menuitem'])) { return ( - (node.properties.type === 'checkbox' || - node.properties.type === 'radio') && - hasProperty(node, 'checked') + (element.properties.type === 'checkbox' || + element.properties.type === 'radio') && + hasProperty(element, 'checked') ) } - if (isElement(node, 'option')) return hasProperty(node, 'selected') + if (isElement(element, 'option')) return hasProperty(element, 'selected') } -function dir(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function dir(query, _1, _2, _3, state) { return state.direction === query.value } -function disabled(query, node) { +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function disabled(_, element) { return ( - isElement(node, [ + isElement(element, [ 'button', 'input', 'select', @@ -113,46 +179,123 @@ function disabled(query, node) { 'option', 'menuitem', 'fieldset' - ]) && hasProperty(node, 'disabled') + ]) && hasProperty(element, 'disabled') ) } -function required(query, node) { +/** + * @param {RulePseudo} query + * @param {Element} element + * @returns {boolean} + */ +function enabled(query, element) { + return !disabled(query, element) +} + +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function required(_, element) { return ( - isElement(node, ['input', 'textarea', 'select']) && - hasProperty(node, 'required') + isElement(element, ['input', 'textarea', 'select']) && + hasProperty(element, 'required') ) } -function readWrite(query, node, index, parent, state) { - return isElement(node, ['input', 'textarea']) - ? !hasProperty(node, 'readOnly') && !hasProperty(node, 'disabled') +/** + * @param {RulePseudo} query + * @param {Element} element + * @returns {boolean} + */ +function optional(query, element) { + return !required(query, element) +} + +/** + * @param {RulePseudo} _ + * @param {Element} element + * @param {number|null} _1 + * @param {Parent|null} _2 + * @param {SelectState} state + * @returns {boolean} + */ +function readWrite(_, element, _1, _2, state) { + return isElement(element, ['input', 'textarea']) + ? !hasProperty(element, 'readOnly') && !hasProperty(element, 'disabled') : state.editableOrEditingHost } -function root(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} element + * @param {number|null} index + * @param {Parent|null} parent + * @param {SelectState} state + * @returns {boolean} + */ +function readOnly(query, element, index, parent, state) { + return !readWrite(query, element, index, parent, state) +} + +/** + * @param {RulePseudo} _ + * @param {Element} element + * @param {number|null} _1 + * @param {Parent|null} parent + * @param {SelectState} state + * @returns {boolean} + */ +function root(_, element, _1, parent, state) { return ( (!parent || parent.type === 'root') && (state.schema.space === 'html' || state.schema.space === 'svg') && - isElement(node, ['html', 'svg']) + isElement(element, ['html', 'svg']) ) } -function scope(query, node, index, parent, state) { - return isElement(node) && state.scopeElements.includes(node) +/** + * @param {RulePseudo} _ + * @param {Element} element + * @param {number|null} _1 + * @param {Parent|null} _2 + * @param {SelectState} state + * @returns {boolean} + */ +function scope(_, element, _1, _2, state) { + return isElement(element) && state.scopeElements.includes(element) } -function empty(query, node) { - return !someChildren(node, check) - +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function empty(_, element) { + return !someChildren(element, check) + + /** + * @param {ElementChild} child + * @returns {boolean} + */ function check(child) { return child.type === 'element' || child.type === 'text' } } -function blank(query, node) { - return !someChildren(node, check) - +/** + * @param {RulePseudo} _ + * @param {Element} element + * @returns {boolean} + */ +function blank(_, element) { + return !someChildren(element, check) + + /** + * @param {ElementChild} child + * @returns {boolean} + */ function check(child) { return ( child.type === 'element' || (child.type === 'text' && !whitespace(child)) @@ -160,67 +303,161 @@ function blank(query, node) { } } -function firstChild(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function firstChild(query, _1, _2, _3, state) { assertDeep(state, query) return state.elementIndex === 0 } -function lang(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function lang(query, _1, _2, _3, state) { return ( state.language !== '' && state.language !== undefined && state.language !== null && + // @ts-ignore never `selectors`. extendedFilter(state.language, commas(query.value)).length > 0 ) } -function lastChild(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function lastChild(query, _1, _2, _3, state) { assertDeep(state, query) return state.elementIndex === state.elementCount - 1 } -function onlyChild(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function onlyChild(query, _1, _2, _3, state) { assertDeep(state, query) return state.elementCount === 1 } -function nthChild(query, node, index, parent, state) { +/** + * @param {RulePseudoNth} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function nthChild(query, _1, _2, _3, state) { assertDeep(state, query) return query.value(state.elementIndex) } -function nthLastChild(query, node, index, parent, state) { +/** + * @param {RulePseudoNth} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function nthLastChild(query, _1, _2, _3, state) { assertDeep(state, query) return query.value(state.elementCount - state.elementIndex - 1) } -function nthOfType(query, node, index, parent, state) { +/** + * @param {RulePseudoNth} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function nthOfType(query, _1, _2, _3, state) { assertDeep(state, query) return query.value(state.typeIndex) } -function nthLastOfType(query, node, index, parent, state) { +/** + * @param {RulePseudoNth} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function nthLastOfType(query, _1, _2, _3, state) { assertDeep(state, query) return query.value(state.typeCount - 1 - state.typeIndex) } -function firstOfType(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function firstOfType(query, _1, _2, _3, state) { assertDeep(state, query) return state.typeIndex === 0 } -function lastOfType(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function lastOfType(query, _1, _2, _3, state) { assertDeep(state, query) return state.typeIndex === state.typeCount - 1 } -function onlyOfType(query, node, index, parent, state) { +/** + * @param {RulePseudo} query + * @param {Element} _1 + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function onlyOfType(query, _1, _2, _3, state) { assertDeep(state, query) return state.typeCount === 1 } -function someChildren(node, check) { - var children = node.children +/** + * @param {Element} element + * @param {(child: ElementChild) => boolean} check + * @returns {boolean} + */ +function someChildren(element, check) { + var children = element.children var index = -1 while (++index < children.length) { @@ -234,6 +471,9 @@ function invalidPseudo() { throw new Error('Invalid pseudo-selector') } +/** + * @param {RulePseudo} query + */ function unknownPseudo(query) { if (query.name) { throw new Error('Unknown pseudo-selector `' + query.name + '`') @@ -242,24 +482,37 @@ function unknownPseudo(query) { throw new Error('Unexpected pseudo-element or empty pseudo-class') } +/** + * @param {SelectState} state + * @param {RulePseudo|RulePseudoNth} query + */ function assertDeep(state, query) { if (state.shallow) { throw new Error('Cannot use `:' + query.name + '` without parent') } } -function has(query, node, index, parent, state) { +/** + * @param {RulePseudoSelector} query + * @param {Element} element + * @param {number|null} _2 + * @param {Parent|null} _3 + * @param {SelectState} state + * @returns {boolean} + */ +function has(query, element, _2, _3, state) { var shallow = state.shallow var one = state.one var scopeElements = state.scopeElements var value = appendScope(query.value) + /** @type {boolean} */ var result state.shallow = false state.one = true - state.scopeElements = [node] + state.scopeElements = [element] - result = any(value, node, state)[0] + result = any(value, element, state).length > 0 state.shallow = shallow state.one = one @@ -268,10 +521,16 @@ function has(query, node, index, parent, state) { return result } +/** + * @param {Selector} value + * @returns {Selectors} + */ function appendScope(value) { + /** @type {Selectors} */ var selector = value.type === 'ruleSet' ? {type: 'selectors', selectors: [value]} : value var index = -1 + /** @type {Rule} */ var rule while (++index < selector.selectors.length) { @@ -284,9 +543,9 @@ function appendScope(value) { rule.pseudos[0].name !== 'scope' ) { selector.selectors[index] = { - type: 'rule', - rule, - pseudos: [{name: 'scope'}] + type: 'ruleSet', + // @ts-ignore pseudos are fine w/ just a name! + rule: {type: 'rule', rule, pseudos: [{name: 'scope'}]} } } } diff --git a/lib/test.js b/lib/test.js index 65daf7d..b447c6e 100644 --- a/lib/test.js +++ b/lib/test.js @@ -1,13 +1,30 @@ +/** + * @typedef {import('./types.js').Rule} Rule + * @typedef {import('./types.js').HastNode} HastNode + * @typedef {import('./types.js').Element} Element + * @typedef {import('./types.js').Parent} Parent + * @typedef {import('./types.js').SelectState} SelectState + * @typedef {import('hast-util-is-element').AssertPredicate} IsElement + */ + import {attribute} from './attribute.js' import {className} from './class-name.js' import {id} from './id.js' import {name} from './name.js' import {pseudo} from './pseudo.js' +import {element} from './util.js' +/** + * @param {Rule} query + * @param {HastNode} node + * @param {number|null} index + * @param {Parent|null} parent + * @param {SelectState} state + * @returns {boolean} + */ export function test(query, node, index, parent, state) { return ( - node && - node.type === 'element' && + element(node) && (!query.tagName || name(query, node)) && (!query.classNames || className(query, node)) && (!query.id || id(query, node)) && diff --git a/lib/types.js b/lib/types.js new file mode 100644 index 0000000..cf05d3a --- /dev/null +++ b/lib/types.js @@ -0,0 +1,78 @@ +/** + * @typedef {import('unist').Node} Node + * @typedef {import('unist').Parent} Parent + * + * @typedef {import('hast').Root} Root + * @typedef {import('hast').Element} Element + * @typedef {import('hast').Properties} Properties + * @typedef {Element|Root} HastParent + * @typedef {import('hast').Parent['children'][number]|Root} HastNode + * @typedef {Element['children'][number]} ElementChild + * @typedef {Properties[string]} PropertyValue + * + * @typedef {import('css-selector-parser').Selector} Selector + * @typedef {import('css-selector-parser').Selectors} Selectors + * @typedef {import('css-selector-parser').RuleSet} RuleSet + * @typedef {import('css-selector-parser').Rule} Rule + * @typedef {import('css-selector-parser').RulePseudo} RulePseudo + * @typedef {import('css-selector-parser').AttrValueType} AttrValueType + * @typedef {Selector|Rule|RulePseudo} Query + * + * Fix for types. + * @typedef {Object} RuleAttr + * @property {string} name + * @property {string} [operator] + * @property {AttrValueType} [valueType] + * @property {string} [value] + * + * More specific type for registered selector pseudos. + * @typedef {Object} RulePseudoSelector + * @property {string} name + * @property {'selector'} valueType + * @property {Selector} value + * + * Overwrite to compile nth-checks once. + * @typedef {Object} RulePseudoNth + * @property {string} name + * @property {'function'} valueType + * @property {(index: number) => boolean} value + * + * @typedef {'html'|'svg'} Space + * @typedef {'auto'|'ltr'|'rtl'} Direction + * @typedef {typeof import('property-information').html} Schema + * @typedef {Schema['property'][string]} Info + * + * @typedef {Object} SelectState + * @property {Array.} [scopeElements] + * @property {SelectIterator} [iterator] + * @property {boolean} [one=false] + * @property {boolean} [shallow=false] + * @property {boolean} [index=false] + * @property {boolean} [found=false] + * @property {Space} [space] + * @property {Schema} [schema] + * @property {string} [language] + * @property {Direction} [direction] + * @property {boolean} [editableOrEditingHost] + * @property {number} [typeIndex] Track siblings + * @property {number} [elementIndex] Track siblings + * @property {number} [typeCount] Track siblings + * @property {number} [elementCount] Track siblings + */ + +/** + * @callback SelectIterator + * @param {Rule} query + * @param {HastNode} node + * @param {number} index + * @param {Parent|null} parent + * @param {SelectState} state + */ + +/** + * @typedef {( + * ((query: Rule, node: HastNode, index: number|null, parent: Parent|null, state: SelectState) => void) + * )} Handler + */ + +export {} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..5a921b2 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,20 @@ +/** + * @typedef {import('./types.js').Node} Node + * @typedef {import('./types.js').Element} Element + * @typedef {import('./types.js').Parent} Parent + * @typedef {import('hast-util-is-element').AssertPredicate} IsElement + */ + +import {convertElement} from 'hast-util-is-element' + +/** + * @param {Node} node + * @returns {node is Parent} + */ +export function parent(node) { + return Array.isArray(node.children) +} + +/** @type {IsElement} */ +// @ts-ignore it works. +export const element = convertElement() diff --git a/package.json b/package.json index fb9e27b..70fea12 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,15 @@ "sideEffects": false, "type": "module", "main": "index.js", + "types": "index.d.ts", "files": [ "lib/", + "index.d.ts", "index.js" ], "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^1.0.0", @@ -52,20 +56,26 @@ "zwitch": "^2.0.0" }, "devDependencies": { + "@types/tape": "^4.0.0", "c8": "^7.0.0", "hastscript": "^7.0.0", "prettier": "^2.0.0", "remark-cli": "^9.0.0", "remark-preset-wooorm": "^8.0.0", + "rimraf": "^3.0.0", "tape": "^5.0.0", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", "unist-builder": "^3.0.0", "xo": "^0.39.0" }, "scripts": { + "prepack": "npm run build && npm run format", + "build": "rimraf \"{lib/**,test/**,}*.d.ts\" && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node test/index.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node test/index.js", - "test": "npm run format && npm run test-coverage" + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { "tabWidth": 2, @@ -88,5 +98,10 @@ "plugins": [ "preset-wooorm" ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true } } diff --git a/test/matches.js b/test/matches.js index 6cba8fe..fe39468 100644 --- a/test/matches.js +++ b/test/matches.js @@ -7,6 +7,7 @@ test('select.matches()', function (t) { t.test('invalid selector', function (t) { t.throws( function () { + // @ts-ignore runtime. matches() }, /Error: Expected `string` as selector, not `undefined`/, @@ -15,6 +16,7 @@ test('select.matches()', function (t) { t.throws( function () { + // @ts-ignore runtime. matches([], h('')) }, /Error: Expected `string` as selector, not ``/, @@ -1269,7 +1271,7 @@ test('select.matches()', function (t) { t.test(':scope', function (t) { t.ok(matches(':scope', h('html')), 'always true for elements') t.ok(matches(':scope', h('p')), 'always true for elements') - t.notOk(matches(':scope', u('text'), '!'), 'always true for elements') + t.notOk(matches(':scope', u('text', '!')), 'always true for elements') t.end() }) diff --git a/test/select-all.js b/test/select-all.js index 02ea064..8aa0163 100644 --- a/test/select-all.js +++ b/test/select-all.js @@ -7,6 +7,7 @@ test('select.selectAll()', function (t) { t.test('invalid selectors', function (t) { t.throws( function () { + // @ts-ignore runtime. selectAll() }, /Error: Expected `string` as selector, not `undefined`/, @@ -15,6 +16,7 @@ test('select.selectAll()', function (t) { t.throws( function () { + // @ts-ignore runtime. selectAll([], h('')) }, /Error: Expected `string` as selector, not ``/, diff --git a/test/select.js b/test/select.js index 06c78f0..448563b 100644 --- a/test/select.js +++ b/test/select.js @@ -7,6 +7,7 @@ test('select.select()', function (t) { t.test('invalid selectors', function (t) { t.throws( function () { + // @ts-ignore runtime. select() }, /Error: Expected `string` as selector, not `undefined`/, @@ -15,6 +16,7 @@ test('select.select()', function (t) { t.throws( function () { + // @ts-ignore runtime. select([], h('')) }, /Error: Expected `string` as selector, not ``/, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2b103bd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["*.js", "lib/**/*.js", "test/**/*.js"], + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + } +}