Comparing version 1.4.0 to 1.5.0
@@ -57,4 +57,4 @@ /*eslint sort-keys: 2*/ | ||
'no-new-object': 2, | ||
'no-tabs': 2, | ||
'no-template-curly-in-string': 2, | ||
'no-tabs': 2, | ||
'no-throw-literal': 2, | ||
@@ -61,0 +61,0 @@ 'no-trailing-spaces': 2, |
138
helpers.js
@@ -1,10 +0,31 @@ | ||
const parser = require('css-what'); | ||
'use strict'; | ||
const CSSwhat = require('css-what'); | ||
const self = module.exports = {}; | ||
self.tokenStream = (css, pseudos) => parser(self.applyCustomPsuedos(css, pseudos)); | ||
self.tokenStreams = (css, pseudos) => { | ||
const streams = CSSwhat | ||
.parse(self.applyCustomPsuedos(css, pseudos)); | ||
const pseudoAliases = { | ||
first: 'first-child', | ||
last: 'last-child', | ||
child: 'nth-child', | ||
contains: 'text-contains', | ||
}; | ||
for (const stream of streams) { | ||
for (const item of stream) { | ||
if (item.type === 'pseudo' && pseudoAliases[item.name]) { | ||
item.name = pseudoAliases[item.name]; | ||
} | ||
} | ||
} | ||
return streams; | ||
}; | ||
self.decorateToken = token => { | ||
const {type, name} = token; | ||
const titleCase = s => s.replace(/^\w/, m => m.toUpperCase()); | ||
token[`is${titleCase(type)}`] = true; | ||
@@ -18,2 +39,3 @@ if (name) { | ||
token.isTagOrUniversal = /^(tag|universal)$/.test(type); | ||
return token; | ||
@@ -23,34 +45,108 @@ }; | ||
self.translateCaseMap = str => { | ||
const letters = str | ||
const az = str | ||
.toLowerCase() | ||
// ISO/IEC 8859-15 (+ greek) lowercase letters. | ||
.replace(/[^a-z\u0161\u017E\u0153\u00E0-\u00FF\u03AC-\u03CE]/g, '') | ||
.split(''); | ||
const az = self.arrayUnique(letters).join(''); | ||
return {az, AZ: az.toUpperCase()}; | ||
.split('') | ||
.filter(self.arrayFilterUnique) | ||
.join(''); | ||
return { | ||
az, | ||
AZ: az.toUpperCase(), | ||
}; | ||
}; | ||
self.arrayUnique = arr => arr.filter((v, i, a) => a.indexOf(v) === i); | ||
self.arrayFilterUnique = (v, i, a) => a.indexOf(v) === i; | ||
self.applyCustomPsuedos = (selector, pseudos) => { | ||
const names = pseudos ? Object.keys(pseudos) : []; | ||
if (! names.length) { | ||
const keys = pseudos | ||
? Object.keys(pseudos) | ||
: []; | ||
if (! keys.length) { | ||
return selector; | ||
} | ||
const {string, restore} = literalCapture(selector); | ||
const patt = new RegExp(`:(${names.join('|')})(?:\\(([^)]+)\\)|(?![\\w-]))`, 'g'); | ||
return restore(string.replace(patt, (_, name, data) => { | ||
const handler = pseudos[name]; | ||
data = (typeof data === 'string') ? restore(data.trim()) : undefined; | ||
return (typeof handler === 'function') ? handler(data) : handler; | ||
})); | ||
let {string, restore} = literalCapture(selector); | ||
const names = keys | ||
.filter(it => ! it.startsWith('/')); | ||
const dataSubPatt = String.raw`(?:\((?<data>[^)]+)\)|(?![\w-]))`; | ||
if (names.length) { | ||
const patt = new RegExp(`:(${names.join('|')})${dataSubPatt}`, 'g'); | ||
string = string | ||
.replace(patt, (_, name, data) => { | ||
const handler = pseudos[name]; | ||
data = typeof data === 'string' | ||
? restore(data.trim()) | ||
: undefined; | ||
return typeof handler === 'function' | ||
? handler(data) | ||
: handler; | ||
}); | ||
} | ||
const regexes = keys | ||
.filter(it => it.startsWith('/')) | ||
.map(it => { | ||
const source = it.slice(1, it.lastIndexOf('/')); | ||
if (! /^[a-z]/.test(source)) { | ||
throw new SyntaxError('Custom pesudo regexes must begin with [a-z] and not use anchors (^$)'); | ||
} | ||
const handler = pseudos[it]; | ||
if (typeof handler !== 'function') { | ||
throw new SyntaxError('Custom pesudo regexes must have a function handler'); | ||
} | ||
return { | ||
pattern: new RegExp(`:(${source})${dataSubPatt}`, 'g'), | ||
handler, | ||
}; | ||
}); | ||
for (const regex of regexes) { | ||
string = string | ||
.replace(regex.pattern, (...matches) => { | ||
// Reduce matches to one object containing named and positional matches. | ||
const result = {}; | ||
// Seperate data. | ||
const named = matches.pop(); | ||
const {data} = named; | ||
delete named.data; | ||
Object.assign(result, named); | ||
// Trim irrelevant match data. | ||
matches.shift(); // Fullmatch. | ||
matches.pop(); // Full input. | ||
matches.pop(); // Offset. | ||
matches.pop(); // Data. | ||
Object.assign(result, matches); | ||
return regex.handler(data, result); | ||
}); | ||
} | ||
return restore(string); | ||
}; | ||
function literalCapture(string) { | ||
const literalCapture = string => { | ||
const literals = []; | ||
return { | ||
literals, | ||
string: string.replace(/(["'])(?:\\\1|.)*?\1/g, m => `__S${literals.push(m)-1}__`), | ||
restore: str => str.replace(/__S(\d+)__/g, (...m) => literals[m[1]]), | ||
string: string | ||
.replace(/(["'])(?:\\\1|.)*?\1/g, m => `__S${literals.push(m)-1}__`), | ||
restore: str => str | ||
.replace(/__S(\d+)__/g, (...m) => literals[m[1]]), | ||
}; | ||
} | ||
}; | ||
const titleCase = string => string.replace(/^\w/, m => m.toUpperCase()); |
84
index.js
@@ -1,7 +0,10 @@ | ||
const {tokenStream, decorateToken, translateCaseMap, | ||
arrayUnique, applyCustomPsuedos} = require('./helpers'); | ||
'use strict'; | ||
const {tokenStreams, decorateToken, translateCaseMap, | ||
arrayFilterUnique, applyCustomPsuedos} = require('./helpers'); | ||
const self = module.exports = function cssToXPath(css, {pseudos}={}) { | ||
const streams = tokenStream(css, pseudos); | ||
const streams = tokenStreams(css, pseudos); | ||
const expressions = []; | ||
for (const stream of streams) { | ||
@@ -17,9 +20,29 @@ stream.forEach(token => decorateToken(token)); | ||
decorateToken(stream[0]); | ||
let tagContext; | ||
for (const item of stream) { | ||
if (item.isTag) { | ||
tagContext = item.name; | ||
} | ||
else if (item.isAxis) { | ||
tagContext = null; | ||
} | ||
else if (tagContext) { | ||
item.tagContext = tagContext; | ||
} | ||
if (item.isPseudo && item.name.endsWith('-of-type') && ! item.tagContext) { | ||
throw new SyntaxError('*-of-type pseudos require a tag context'); | ||
} | ||
} | ||
expressions.push(xpathExpression(stream)); | ||
} | ||
return (expressions.length > 1) ? `(${expressions.join('|')})` : expressions[0]; | ||
return expressions.length > 1 | ||
? `(${expressions.join('|')})` | ||
: expressions[0]; | ||
}; | ||
self.subExpression = (css, {pseudos}={}) => { | ||
const streams = tokenStream(css, pseudos) | ||
const streams = tokenStreams(css, pseudos) | ||
.map(stream => { | ||
@@ -30,2 +53,3 @@ return stream | ||
}); | ||
return subExpression(streams, {operator: 'or'}); | ||
@@ -37,4 +61,6 @@ }; | ||
function xpathExpression(tokens) { | ||
let xpath = []; | ||
let filters = []; | ||
const commitFilters = () => { | ||
@@ -49,5 +75,9 @@ const flattened = flattenFilters(filters); | ||
for (let i = 0; i < tokens.length; i++) { | ||
const token = tokens[i]; | ||
const previous = (i > 0) ? tokens[i-1] : {}; | ||
const previous = i > 0 | ||
? tokens[i-1] | ||
: {}; | ||
if (previous.isNonSiblingAxis && ! token.isTagOrUniversal && ! token.isPseudoComment) { | ||
@@ -115,2 +145,15 @@ xpath.push('*'); | ||
} | ||
else if ( | ||
token.isPseudoNthChild | ||
|| token.isPseudoFirstChild | ||
|| token.isPseudoLastChild | ||
|| token.isPseudoOnlyChild | ||
) { | ||
const tokenFilters = resolveAsFilters(token); | ||
if (token.tagContext) { | ||
xpath.splice(-1, 1, '*'); | ||
tokenFilters.unshift(`name() = '${token.tagContext}'`); | ||
} | ||
filters.push(...tokenFilters); | ||
} | ||
else { | ||
@@ -125,2 +168,3 @@ filters.push(...resolveAsFilters(token)); | ||
commitFilters(); | ||
return xpath.join(''); | ||
@@ -138,2 +182,3 @@ } | ||
}); | ||
return flattenFilters(stack, options); | ||
@@ -193,6 +238,8 @@ } | ||
break; | ||
case 'first-child': | ||
case 'first-child': // fallthrough | ||
case 'first-of-type': | ||
elements.push(`position() = 1`); | ||
break; | ||
case 'last-child': | ||
case 'last-child': // fallthrough | ||
case 'last-of-type': | ||
elements.push(`position() = last()`); | ||
@@ -203,3 +250,4 @@ break; | ||
break; | ||
case 'nth-child': { | ||
case 'nth-child': // fallthrough | ||
case 'nth-of-type': { | ||
const aliases = { | ||
@@ -288,2 +336,3 @@ odd: '2n+1', | ||
} | ||
return elements; | ||
@@ -293,9 +342,12 @@ } | ||
function flattenFilters(filters, {operator='and'}={}) { | ||
filters = arrayUnique(filters.map(filter => { | ||
if ((filters.length > 1) && /[=<>]|\b(and|or)\b/i.test(filter)) { | ||
return `(${filter})`; | ||
} | ||
return filter; | ||
})); | ||
return filters.length ? filters.join(` ${operator} `) : ''; | ||
return filters | ||
.map(filter => { | ||
if ((filters.length > 1) && /[=<>]|\b(and|or)\b/i.test(filter)) { | ||
return `(${filter})`; | ||
} | ||
return filter; | ||
}) | ||
.filter(arrayFilterUnique) | ||
.join(` ${operator} `); | ||
} |
{ | ||
"name": "csstoxpath", | ||
"version": "1.4.0", | ||
"version": "1.5.0", | ||
"description": "CSS to XPath", | ||
@@ -24,9 +24,9 @@ "main": "index.js", | ||
"dependencies": { | ||
"css-what": "~2.1.0" | ||
"css-what": "~3.3.0" | ||
}, | ||
"devDependencies": { | ||
"chai": "~4.1.2", | ||
"eslint": "~5.3.0", | ||
"mocha": "~4.0.1" | ||
"chai": "~4.2.0", | ||
"eslint": "~7.9.0", | ||
"mocha": "~8.1.3" | ||
} | ||
} |
@@ -46,2 +46,10 @@ [![Build Status](https://travis-ci.org/peteboere/csstoxpath.svg?branch=master)](https://travis-ci.org/peteboere/csstoxpath) | ||
## Aliased pseudos | ||
* `:first` is aliased to `:first-child` | ||
* `:last` is aliased to `:last-child` | ||
* `:child` is aliased to `:nth-child` | ||
* `:contains` is aliased to `:text-contains` | ||
## Author pseudos | ||
@@ -54,13 +62,15 @@ | ||
* If the `pseudos` option is set the CSS expression is | ||
* preprocessed before generating the XPath expression. | ||
* In this case as follows: | ||
* preprocessed before generating the XPath expression: | ||
* | ||
* before :radio:nth(2) | ||
* after input[type="radio"]:nth-child(2) | ||
* :radio | ||
* => input[type="radio"] | ||
* | ||
* :element-1(Hello) | ||
* => element:child(1):contains("Hello") | ||
*/ | ||
const cssToXpath = require('csstoxpath'); | ||
const xpathExpr = cssToXpath(':radio:nth(2)', { | ||
const xpathExpr = cssToXpath(':radio, :element-1(Hello)', { | ||
pseudos: { | ||
radio: 'input[type="radio"]', | ||
nth: data => `:nth-child(${data})`, | ||
[/element-(\d+)/]: (data, m) => `element:child(${m[1]}):contains("${data}")`, | ||
} | ||
@@ -70,9 +80,16 @@ }); | ||
## Unsupported psuedos | ||
## Limitations | ||
The following may be supported at a later date: | ||
The following pseudos are partially supported as they require a tag context: | ||
* `:nth-of-type` | ||
* `:first-of-type` | ||
* `:last-of-type` | ||
The following pseudos are currently unsupported: | ||
* `:nth-last-child` | ||
* `:nth-last-of-type` | ||
Part-dynamic pseudos are excluded as they can only be partially supported by attribute matching: | ||
Dynamic pseudos are excluded as they can only be partially supported by attribute matching: | ||
@@ -87,4 +104,3 @@ * `:checked` | ||
* `:*-of-type` | ||
* States: `:hover`, `:focus`, `:active`, `:visited`, `:target` etc. | ||
* Elements: `::before`, `::after`, `::first-letter` etc. |
97
test.js
@@ -0,1 +1,2 @@ | ||
'use strict'; | ||
/*eslint no-console: 0*/ | ||
@@ -7,2 +8,3 @@ | ||
const samples = [ | ||
'Simple', | ||
@@ -12,13 +14,7 @@ ['a', `//a`], | ||
['#a', `//*[@id = 'a']`], | ||
['.a', | ||
`//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' a ')]`, | ||
], | ||
['.a.b', | ||
`//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' a ') and contains(concat(' ', normalize-space(@class), ' '), ' b ')]`, | ||
], | ||
['.a', `//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' a ')]`], | ||
['.a.b', `//*[@class and contains(concat(' ', normalize-space(@class), ' '), ' a ') and contains(concat(' ', normalize-space(@class), ' '), ' b ')]`], | ||
'Child axis', | ||
['#a > .b:last-child', | ||
`//*[@id = 'a']/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' b ') and (position() = last())]`, | ||
], | ||
['#a > .b:last-child', `//*[@id = 'a']/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' b ') and (position() = last())]`], | ||
@@ -62,2 +58,3 @@ 'Decendant axis', | ||
':first-child', | ||
[':first', `//*[position() = 1]`], | ||
[':first-child', `//*[position() = 1]`], | ||
@@ -68,3 +65,7 @@ ['.a:first-child', | ||
// first-child with tag context. | ||
['div:first-child', `//*[(name() = 'div') and (position() = 1)]`], | ||
':last-child', | ||
[':last', `//*[position() = last()]`], | ||
[':last-child', `//*[position() = last()]`], | ||
@@ -74,4 +75,7 @@ ['.a:last-child', | ||
], | ||
// With tag context. | ||
['div[foo]:last-child(4)', `//*[@foo and (name() = 'div') and (position() = last())]`], | ||
':nth-child', | ||
[':child(1n)', `//*[position() >= 0]`], | ||
[':nth-child(1n)', `//*[position() >= 0]`], | ||
@@ -90,3 +94,14 @@ [':nth-child(1n+2)', `//*[position() - 2 >= 0]`], | ||
[':nth-child(-2n+3)', `//*[position() - 3 <= 0 and (position() - 3) mod -2 = 0]`], | ||
// With tag context. | ||
['div[foo]:nth-child(4)', `//*[@foo and (name() = 'div') and (position() = 4)]`], | ||
':nth-of-type', | ||
['div:nth-of-type(4)', `//div[position() = 4]`], | ||
':last-of-type', | ||
['div:last-of-type', `//div[position() = last()]`], | ||
':first-of-type', | ||
['div:first-of-type', `//div[position() = 1]`], | ||
':root', | ||
@@ -102,2 +117,4 @@ [':root', `/*`], | ||
[':only-child', `//*[last() = 1]`], | ||
// With tag context. | ||
['div:only-child', `//*[(name() = 'div') and (last() = 1)]`], | ||
@@ -131,2 +148,4 @@ ':empty', | ||
':text-contains', | ||
[':contains("foo bar ")', | ||
`//*[contains(translate(normalize-space(), 'FOBAR', 'fobar'), "foo bar")]`], | ||
[':text-contains("foo bar ")', | ||
@@ -233,5 +252,3 @@ `//*[contains(translate(normalize-space(), 'FOBAR', 'fobar'), "foo bar")]`], | ||
':nth-last-child(1)', | ||
':nth-of-type', | ||
':nth-last-of-type', | ||
':last-of-type', | ||
':only-of-type', | ||
@@ -251,8 +268,23 @@ ':checked', | ||
describe('Unspecified tag context *-of-type psuedos', function () { | ||
const unsupported = [ | ||
':nth-of-type', | ||
':first-of-type', | ||
':last-of-type', | ||
]; | ||
for (const css of unsupported) { | ||
it(`should error for unsupported selector '${css}'`, function () { | ||
expect(() => cssToXpath(css)).to.throw(Error, /-of-type pseudos require a tag context/); | ||
}); | ||
} | ||
}); | ||
describe('Author pseudo preprocessing', function () { | ||
const pseudos = { | ||
foo: 'foo', | ||
// First alias can be overriden. | ||
first: ':first-child:not(:last-child)', | ||
nth: data => `:nth-child(${data})`, | ||
radio: `input[type="radio"]`, | ||
// Child alias can be overriden. | ||
child(data) { | ||
@@ -268,2 +300,27 @@ if (data) { | ||
}, | ||
foo: 'foo', | ||
nth: data => `:nth-child(${data})`, | ||
radio: `input[type="radio"]`, | ||
// Regexes. | ||
[/custom-(?<position>\d+)(-(?<type>heading|link))?/](data, matches) { | ||
let selector = `element:nth-of-type(${matches.position})`; | ||
switch (matches.type) { | ||
case 'heading': | ||
selector += ' > h1'; | ||
break; | ||
case 'link': | ||
selector += ' > a'; | ||
break; | ||
} | ||
if (data) { | ||
selector += `:contains(${data})`; | ||
} | ||
return selector; | ||
}, | ||
}; | ||
@@ -284,3 +341,3 @@ | ||
]], | ||
// Should be ignored. | ||
// Should passthrough unaffected. | ||
['a :first-child c', [ | ||
@@ -304,3 +361,3 @@ `a :first-child c`, | ||
`input[type="radio"]:nth-child(2)`, | ||
`//input[(@type = 'radio') and (position() = 2)]`, | ||
`//*[(@type = 'radio') and (name() = 'input') and (position() = 2)]`, | ||
]], | ||
@@ -311,2 +368,10 @@ [':foo("(:foo)")', [ | ||
]], | ||
[':custom-1-heading', [ | ||
'element:nth-of-type(1) > h1', | ||
`//element[position() = 1]/h1`, | ||
]], | ||
[':custom-2-link(Some text)', [ | ||
'element:nth-of-type(2) > a:contains(Some text)', | ||
`//element[position() = 2]/a[contains(translate(normalize-space(), 'SOMETX', 'sometx'), "some text")]`, | ||
]], | ||
]; | ||
@@ -313,0 +378,0 @@ |
Sorry, the diff of this file is not supported yet
AI-detected possible typosquat
Supply chain riskAI has identified this package as a potential typosquat of a more popular package. This suggests that the package may be intentionally mimicking another package's name, description, or other metadata.
Found 1 instance in 1 package
37914
8
885
103
0
+ Addedcss-what@3.3.0(transitive)
- Removedcss-what@2.1.3(transitive)
Updatedcss-what@~3.3.0