diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee318ca..fb63387 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,5 +17,5 @@ jobs: strategy: matrix: node: - - lts/hydrogen + - lts/gallium - node diff --git a/.npmrc b/.npmrc index 9951b11..3757b30 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -package-lock=false ignore-scripts=true +package-lock=false diff --git a/lib/index.js b/lib/index.js index a13e81a..59e32c3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,84 +1,80 @@ /** - * @typedef {import('css-selector-parser').RuleAttr} RuleAttr - * @typedef {import('css-selector-parser').RulePseudo} RulePseudo - * @typedef {import('css-selector-parser').Rule} Rule + * @typedef {import('css-selector-parser').AstAttribute} AstAttribute + * @typedef {import('css-selector-parser').AstRule} AstRule * * @typedef {import('hast').Element} HastElement * @typedef {import('hast').Properties} HastProperties - * - * @typedef {'html' | 'svg'} Space - * Name of namespace. - * + */ + +/** * @typedef Options * Configuration. * @property {Space} [space] - * Which space first element in the selector is in. + * Which space first element in the selector is in (default: `'html'`). * * When an `svg` element is created in HTML, the space is automatically * switched to SVG. * + * @typedef {'html' | 'svg'} Space + * Name of namespace. + * * @typedef State * Info on current context. * @property {Space} space * Current space. */ +import {createParser} from 'css-selector-parser' +import {ok as assert} from 'devlop' import {h, s} from 'hastscript' -import {CssSelectorParser} from 'css-selector-parser' -const parser = new CssSelectorParser() +const cssSelectorParse = createParser({syntax: 'selectors-4'}) -parser.registerNestingOperators('>', '+', '~') -// Register these so we can throw nicer errors. -parser.registerAttrEqualityMods('~', '|', '^', '$', '*') +/** @type {Options} */ +const emptyOptions = {} /** * Create one or more `Element`s from a CSS selector. * * @param {string | null | undefined} [selector=''] - * CSS selector. - * @param {Space | Options | null | undefined} [space='html'] - * Space or configuration. + * CSS selector (default: `''`). + * @param {Options | null | undefined} [options] + * Configuration (optional). * @returns {HastElement} * Built tree. */ -export function fromSelector(selector, space) { - /** @type {State} */ - const state = { - space: - (space && typeof space === 'object' && space.space) || - (typeof space === 'string' && space) || - 'html' - } +export function fromSelector(selector, options) { + const settings = options || emptyOptions - const query = parser.parse(selector || '') + const query = cssSelectorParse(selector || '*') - if (query && query.type === 'selectors') { + if (query.rules.length > 1) { throw new Error('Cannot handle selector list') } - const result = query ? rule(query.rule, state) : [] + const head = query.rules[0] + assert(head, 'expected rule') if ( - query && - query.rule.rule && - (query.rule.rule.nestingOperator === '+' || - query.rule.rule.nestingOperator === '~') + head.nestedRule && + (head.nestedRule.combinator === '+' || head.nestedRule.combinator === '~') ) { throw new Error( 'Cannot handle sibling combinator `' + - query.rule.rule.nestingOperator + + head.nestedRule.combinator + '` at root' ) } - return result[0] || build(state.space)('') + const result = rule(head, {space: settings.space || 'html'}) + + return result[0] } /** * Turn a rule into one or more elements. * - * @param {Rule} query + * @param {AstRule} query * Selector. * @param {State} state * Info on current context. @@ -87,82 +83,90 @@ export function fromSelector(selector, space) { */ function rule(query, state) { const space = - state.space === 'html' && query.tagName === 'svg' ? 'svg' : state.space + state.space === 'html' && + query.tag && + query.tag.type === 'TagName' && + query.tag.name === 'svg' + ? 'svg' + : state.space + + const pseudoClass = query.pseudoClasses ? query.pseudoClasses[0] : undefined + + if (pseudoClass) { + if (pseudoClass.name) { + throw new Error('Cannot handle pseudo class `' + pseudoClass.name + '`') + /* c8 ignore next 4 -- types say this can occur, but I don’t understand how */ + } - checkPseudos(query.pseudos || []) + throw new Error('Cannot handle empty pseudo class') + } - const node = build(space)(query.tagName === '*' ? '' : query.tagName || '', { - id: query.id, + if (query.pseudoElement) { + throw new Error( + 'Cannot handle pseudo element `' + query.pseudoElement + '`' + ) + } + + const name = query.tag && query.tag.type === 'TagName' ? query.tag.name : '' + + const node = build(space)(name, { + id: query.ids ? query.ids[query.ids.length - 1] : undefined, className: query.classNames, - ...attrsToHast(query.attrs || []) + ...attributesToHast(query.attributes) }) const results = [node] - if (query.rule) { + if (query.nestedRule) { // Sibling. if ( - query.rule.nestingOperator === '+' || - query.rule.nestingOperator === '~' + query.nestedRule.combinator === '+' || + query.nestedRule.combinator === '~' ) { - results.push(...rule(query.rule, state)) + results.push(...rule(query.nestedRule, state)) } // Descendant. else { - node.children.push(...rule(query.rule, {space})) + node.children.push(...rule(query.nestedRule, {space})) } } return results } -/** - * Check pseudo selectors. - * - * @param {Array} pseudos - * Pseudo selectors. - * @returns {void} - * Nothing. - * @throws {Error} - * When a pseudo is defined. - */ -function checkPseudos(pseudos) { - const pseudo = pseudos[0] - - if (pseudo) { - if (pseudo.name) { - throw new Error('Cannot handle pseudo-selector `' + pseudo.name + '`') - } - - throw new Error('Cannot handle pseudo-element or empty pseudo-class') - } -} - /** * Turn attribute selectors into properties. * - * @param {Array} attrs + * @param {Array | undefined} attributes * Attribute selectors. * @returns {HastProperties} * Properties. */ -function attrsToHast(attrs) { +function attributesToHast(attributes) { /** @type {HastProperties} */ const props = {} let index = -1 - while (++index < attrs.length) { - const attr = attrs[index] - - if ('operator' in attr) { - if (attr.operator === '=') { - props[attr.name] = attr.value + if (attributes) { + while (++index < attributes.length) { + const attr = attributes[index] + + if ('operator' in attr) { + if (attr.operator === '=') { + const value = attr.value + + // eslint-disable-next-line max-depth + if (value) { + assert(value.type === 'String', 'substitution are not enabled') + props[attr.name] = value.value + } + } else { + throw new Error( + 'Cannot handle attribute equality modifier `' + attr.operator + '`' + ) + } } else { - throw new Error( - 'Cannot handle attribute equality modifier `' + attr.operator + '`' - ) + props[attr.name] = true } - } else { - props[attr.name] = true } } diff --git a/package.json b/package.json index a4a6027..971a6d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hast-util-from-selector", - "version": "2.0.1", + "version": "3.0.0", "description": "hast utility to parse CSS selectors to hast nodes", "license": "MIT", "keywords": [ @@ -28,56 +28,60 @@ ], "sideEffects": false, "type": "module", - "main": "index.js", - "types": "index.d.ts", + "exports": "./index.js", "files": [ "lib/", "index.d.ts", "index.js" ], "dependencies": { - "@types/hast": "^2.0.0", - "css-selector-parser": "^1.0.0", - "hastscript": "^7.0.0" + "@types/hast": "^3.0.0", + "css-selector-parser": "^2.0.0", + "devlop": "^1.0.0", + "hastscript": "^8.0.0" }, "devDependencies": { - "@types/node": "^18.0.0", - "c8": "^7.0.0", - "prettier": "^2.0.0", + "@types/node": "^20.0.0", + "c8": "^8.0.0", + "prettier": "^3.0.0", "remark-cli": "^11.0.0", "remark-preset-wooorm": "^9.0.0", "type-coverage": "^2.0.0", - "typescript": "^4.0.0", - "unist-builder": "^3.0.0", - "xo": "^0.53.0" + "typescript": "^5.0.0", + "unist-builder": "^4.0.0", + "xo": "^0.55.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.js", - "test-coverage": "c8 --check-coverage --100 --reporter lcov npm run test-api", - "test": "npm run format && npm run test-coverage" + "test-coverage": "c8 --100 --reporter lcov npm run test-api", + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { - "tabWidth": 2, - "useTabs": false, - "singleQuote": true, "bracketSpacing": false, "semi": false, - "trailingComma": "none" - }, - "xo": { - "prettier": true + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false }, "remarkConfig": { "plugins": [ - "preset-wooorm" + "remark-preset-wooorm" ] }, "typeCoverage": { "atLeast": 100, "detail": true, + "ignoreCatch": true, "strict": true + }, + "xo": { + "prettier": true, + "rules": { + "unicorn/prefer-at": "off" + } } } diff --git a/readme.md b/readme.md index ff87b8a..07e62c4 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ * [Install](#install) * [Use](#use) * [API](#api) - * [`fromSelector(selector?[, options|space])`](#fromselectorselector-optionsspace) + * [`fromSelector(selector?[, options])`](#fromselectorselector-options) * [`Options`](#options) * [`Space`](#space) * [Support](#support) @@ -43,7 +43,7 @@ and similar to [`hastscript`][hastscript]. ## Install This package is [ESM only][esm]. -In Node.js (version 14.14+ and 16.0+), install with [npm][]: +In Node.js (version 16+), install with [npm][]: ```sh npm install hast-util-from-selector @@ -52,14 +52,14 @@ npm install hast-util-from-selector In Deno with [`esm.sh`][esmsh]: ```js -import {fromSelector} from 'https://esm.sh/hast-util-from-selector@2' +import {fromSelector} from 'https://esm.sh/hast-util-from-selector@3' ``` In browsers with [`esm.sh`][esmsh]: ```html ``` @@ -68,7 +68,7 @@ In browsers with [`esm.sh`][esmsh]: ```js import {fromSelector} from 'hast-util-from-selector' -console.log(fromSelector('p svg[viewbox=0 0 10 10] circle[cx=10][cy=10][r=10]')) +console.log(fromSelector('p svg[viewbox="0 0 10 10"] circle[cx=10][cy=10][r=10]')) ``` Yields: @@ -98,21 +98,19 @@ Yields: ## API -This package exports the identifier [`fromSelector`][fromselector]. +This package exports the identifier [`fromSelector`][api-from-selector]. There is no default export. -### `fromSelector(selector?[, options|space])` +### `fromSelector(selector?[, options])` Create one or more [`Element`][element]s from a CSS selector. ###### Parameters -* `selector` (`string`, optional) +* `selector` (`string`, default: `''`) — CSS selector -* `options` ([`Options`][options], optional) +* `options` ([`Options`][api-options], optional) — configuration -* `space` ([`Space`][space], optional) - — treated as `options.space` ###### Returns @@ -124,7 +122,7 @@ Configuration (TypeScript type). ###### Fields -* `space` ([`Space`][space], optional) +* `space` ([`Space`][api-space], default: `'html'`) — which space first element in the selector is in. When an `svg` element is created in HTML, the space is automatically switched to SVG @@ -155,14 +153,18 @@ type Space = 'html' | 'svg' ## Types This package is fully typed with [TypeScript][]. -It exports the additional types [`Options`][options] and [`Space`][space]. +It exports the additional types [`Options`][api-options] and +[`Space`][api-space]. ## Compatibility -Projects maintained by the unified collective are compatible with all maintained +Projects maintained by the unified collective are compatible with maintained versions of Node.js. -As of now, that is Node.js 14.14+ and 16.0+. -Our projects sometimes work with older versions, but this is not guaranteed. + +When we cut a new major release, we drop support for unmaintained versions of +Node. +This means we try to keep the current release line, +`hast-util-from-selector@^3`, compatible with Node.js 16. ## Security @@ -206,9 +208,9 @@ abide by its terms. [downloads]: https://www.npmjs.com/package/hast-util-from-selector -[size-badge]: https://img.shields.io/bundlephobia/minzip/hast-util-from-selector.svg +[size-badge]: https://img.shields.io/badge/dynamic/json?label=minzipped%20size&query=$.size.compressedSize&url=https://deno.bundlejs.com/?q=hast-util-from-selector -[size]: https://bundlephobia.com/result?p=hast-util-from-selector +[size]: https://bundlejs.com/?q=hast-util-from-selector [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg @@ -252,8 +254,8 @@ abide by its terms. [hastscript]: https://github.com/syntax-tree/hastscript -[fromselector]: #fromselectorselector-optionsspace +[api-from-selector]: #fromselectorselector-options -[options]: #options +[api-options]: #options -[space]: #space +[api-space]: #space diff --git a/test.js b/test.js index ddea49d..d854fa8 100644 --- a/test.js +++ b/test.js @@ -1,196 +1,183 @@ import assert from 'node:assert/strict' import test from 'node:test' import {h, s} from 'hastscript' -import {fromSelector} from './index.js' -import * as mod from './index.js' - -test('fromSelector()', () => { - assert.deepEqual( - Object.keys(mod).sort(), - ['fromSelector'], - 'should expose the public api' - ) - - assert.throws( - () => { +import {fromSelector} from 'hast-util-from-selector' + +test('fromSelector', async function (t) { + await t.test('should expose the public api', async function () { + assert.deepEqual( + Object.keys(await import('hast-util-from-selector')).sort(), + ['fromSelector'] + ) + }) + + await t.test('should throw w/ invalid selector', async function () { + assert.throws(function () { fromSelector('@supports (transform-origin: 5% 5%) {}') - }, - /Error: Rule expected but "@" found/, - 'should throw w/ invalid selector' - ) + }, /Expected rule but "@" found/) + }) - assert.throws( - () => { + await t.test('should throw w/ multiple selector', async function () { + assert.throws(function () { fromSelector('a, b') - }, - /Error: Cannot handle selector list/, - 'should throw w/ multiple selector' - ) + }, /Cannot handle selector list/) + }) - assert.throws( - () => { - fromSelector('a + b') - }, - /Error: Cannot handle sibling combinator `\+` at root/, - 'should throw w/ next-sibling combinator at root' + await t.test( + 'should throw w/ next-sibling combinator at root', + async function () { + assert.throws(function () { + fromSelector('a + b') + }, /Cannot handle sibling combinator `\+` at root/) + } ) - assert.throws( - () => { - fromSelector('a ~ b') - }, - /Error: Cannot handle sibling combinator `~` at root/, - 'should throw w/ subsequent-sibling combinator at root' + await t.test( + 'should throw w/ subsequent-sibling combinator at root', + async function () { + assert.throws(function () { + fromSelector('a ~ b') + }, /Cannot handle sibling combinator `~` at root/) + } ) - assert.throws( - () => { + await t.test('should throw w/ attribute modifiers', async function () { + assert.throws(function () { fromSelector('[foo%=bar]') - }, - /Error: Expected "=" but "%" found./, - 'should throw w/ attribute modifiers' - ) + }, /Expected a valid attribute selector operator/) + }) - assert.throws( - () => { + await t.test('should throw w/ attribute modifiers', async function () { + assert.throws(function () { fromSelector('[foo~=bar]') - }, - /Error: Cannot handle attribute equality modifier `~=`/, - 'should throw w/ attribute modifiers' - ) + }, /Cannot handle attribute equality modifier `~=`/) + }) - assert.throws( - () => { + await t.test('should throw on pseudo classes', async function () { + assert.throws(function () { fromSelector(':active') - }, - /Error: Cannot handle pseudo-selector `active`/, - 'should throw on pseudo classes' - ) + }, /Cannot handle pseudo class `active`/) + }) - assert.throws( - () => { + await t.test('should throw on pseudo class “functions”', async function () { + assert.throws(function () { fromSelector(':nth-foo(2n+1)') - }, - /Error: Cannot handle pseudo-selector `nth-foo`/, - 'should throw on pseudo class “functions”' - ) + }, /Unknown pseudo-class/) + }) - assert.throws( - () => { + await t.test('should throw on invalid pseudo elements', async function () { + assert.throws(function () { fromSelector('::before') - }, - /Error: Cannot handle pseudo-element or empty pseudo-class/, - 'should throw on invalid pseudo elements' - ) - - assert.deepEqual(fromSelector(), h(''), 'should support no selector') - assert.deepEqual(fromSelector(''), h(''), 'should support the empty string') - assert.deepEqual(fromSelector(' '), h(''), 'should support whitespace only') - assert.deepEqual( - fromSelector('*'), - h(''), - 'should support the universal selector' - ) - - assert.deepEqual( - fromSelector('p i s'), - h('p', h('i', h('s'))), - 'should support the descendant combinator' - ) - - assert.deepEqual( - fromSelector('p > i > s'), - h('p', h('i', h('s'))), - 'should support the child combinator' - ) - - assert.deepEqual( - fromSelector('p i + s'), - h('p', [h('i'), h('s')]), - 'should support the next-sibling combinator' - ) - - assert.deepEqual( - fromSelector('p i ~ s'), - h('p', [h('i'), h('s')]), - 'should support the subsequent-sibling combinator' - ) - - assert.deepEqual(fromSelector('a'), h('a'), 'should support a tag name') - assert.deepEqual(fromSelector('.a'), h('.a'), 'should support a class') - assert.deepEqual( - fromSelector('a.b'), - h('a.b'), - 'should support a tag and a class' - ) - assert.deepEqual(fromSelector('#b'), h('#b'), 'should support an id') - assert.deepEqual( - fromSelector('a#b'), - h('a#b'), - 'should support a tag and an id' - ) - assert.deepEqual( - fromSelector('a#b.c.d'), - h('a#b.c.d'), - 'should support all together' - ) - assert.deepEqual(fromSelector('a#b#c'), h('a#c'), 'should use the last id') - assert.deepEqual(fromSelector('A').tagName, 'a', 'should normalize casing') - - assert.deepEqual( - fromSelector('[a]'), - h('', {a: true}), - 'should support attributes (#1)' - ) - assert.deepEqual( - fromSelector('[a=b]'), - h('', {a: 'b'}), - 'should support attributes (#2)' - ) - - assert.deepEqual( - fromSelector('.a.b[class=c]'), - h('.a.b.c'), - 'should support class and class attributes' - ) - - assert.deepEqual(fromSelector('altGlyph').tagName, 'altglyph', 'space (#1)') - - assert.deepEqual( - fromSelector('altGlyph', 'svg').tagName, - 'altGlyph', - 'space (#2)' - ) - - assert.deepEqual( - fromSelector('altGlyph', {space: 'svg'}).tagName, - 'altGlyph', - 'space (#3)' - ) - - assert.deepEqual( - // @ts-expect-error: fine. - fromSelector('svg altGlyph').children[0].tagName, - 'altGlyph', - 'space (#4)' - ) - - assert.deepEqual( - // @ts-expect-error: fine. - fromSelector('div svg + altGlyph').children[1].tagName, - 'altglyph', - 'space (#5)' - ) - - assert.deepEqual( - fromSelector( - 'p svg[viewbox=0 0 10 10] circle[cx=10][cy=10][r=10] altGlyph' - ), - h('p', [ - s('svg', {viewBox: '0 0 10 10'}, [ - s('circle', {cx: '10', cy: '10', r: '10'}, [s('altGlyph')]) + }, /Cannot handle pseudo element `before`/) + }) + + await t.test('should support no selector', async function () { + assert.deepEqual(fromSelector(), h('')) + }) + + await t.test('should support the empty string', async function () { + assert.deepEqual(fromSelector(''), h('')) + }) + + await t.test('should support the universal selector', async function () { + assert.deepEqual(fromSelector('*'), h('')) + }) + + await t.test('should support the descendant combinator', async function () { + assert.deepEqual(fromSelector('p i s'), h('p', h('i', h('s')))) + }) + + await t.test('should support the child combinator', async function () { + assert.deepEqual(fromSelector('p > i > s'), h('p', h('i', h('s')))) + }) + + await t.test('should support the next-sibling combinator', async function () { + assert.deepEqual(fromSelector('p i + s'), h('p', [h('i'), h('s')])) + }) + + await t.test( + 'should support the subsequent-sibling combinator', + async function () { + assert.deepEqual(fromSelector('p i ~ s'), h('p', [h('i'), h('s')])) + } + ) + + await t.test('should support a tag name', async function () { + assert.deepEqual(fromSelector('a'), h('a')) + }) + + await t.test('should support a class', async function () { + assert.deepEqual(fromSelector('.a'), h('.a')) + }) + + await t.test('should support a tag and a class', async function () { + assert.deepEqual(fromSelector('a.b'), h('a.b')) + }) + + await t.test('should support an id', async function () { + assert.deepEqual(fromSelector('#b'), h('#b')) + }) + + await t.test('should support a tag and an id', async function () { + assert.deepEqual(fromSelector('a#b'), h('a#b')) + }) + + await t.test('should support all together', async function () { + assert.deepEqual(fromSelector('a#b.c.d'), h('a#b.c.d')) + }) + + await t.test('should use the last id', async function () { + assert.deepEqual(fromSelector('a#b#c'), h('a#c')) + }) + + await t.test('should normalize casing', async function () { + assert.equal(fromSelector('A').tagName, 'a') + }) + + await t.test('should support attributes (#1)', async function () { + assert.deepEqual(fromSelector('[a]'), h('', {a: true})) + }) + + await t.test('should support attributes (#2)', async function () { + assert.deepEqual(fromSelector('[a=b]'), h('', {a: 'b'})) + }) + + await t.test('should support class and class attributes', async function () { + assert.deepEqual(fromSelector('.a.b[class=c]'), h('.a.b.c')) + }) + + await t.test('should support space (#1)', async function () { + assert.equal(fromSelector('altGlyph').tagName, 'altglyph') + }) + + await t.test('should support space (#2)', async function () { + assert.equal(fromSelector('altGlyph', {space: 'svg'}).tagName, 'altGlyph') + }) + + await t.test('should support space (#3)', async function () { + const result = fromSelector('svg altGlyph') + const child = result.children[0] + assert(child.type === 'element') + assert.equal(child.tagName, 'altGlyph') + }) + + await t.test('should support space (#4)', async function () { + const result = fromSelector('div svg + altGlyph') + const child = result.children[1] + assert(child.type === 'element') + assert.equal(child.tagName, 'altglyph') + }) + + await t.test('should support space (#5)', async function () { + assert.deepEqual( + fromSelector( + 'p svg[viewbox="0 0 10 10"] circle[cx=10][cy=10][r=10] altGlyph' + ), + h('p', [ + s('svg', {viewBox: '0 0 10 10'}, [ + s('circle', {cx: '10', cy: '10', r: '10'}, [s('altGlyph')]) + ]) ]) - ]), - 'space (#6)' - ) + ) + }) }) diff --git a/tsconfig.json b/tsconfig.json index 1bc9e99..82cc749 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,15 @@ { - "include": ["**/**.js"], - "exclude": ["coverage/", "node_modules/"], "compilerOptions": { "checkJs": true, + "customConditions": ["development"], "declaration": true, "emitDeclarationOnly": true, "exactOptionalPropertyTypes": true, - "forceConsistentCasingInFileNames": true, - "lib": ["es2020"], + "lib": ["es2022"], "module": "node16", - "newLine": "lf", - "skipLibCheck": true, "strict": true, - "target": "es2020" - } + "target": "es2022" + }, + "exclude": ["coverage/", "node_modules/"], + "include": ["**/*.js"] }