diff --git a/src/js/constant.js b/src/js/constant.js index 63e8ee7..8176cc6 100644 --- a/src/js/constant.js +++ b/src/js/constant.js @@ -3,6 +3,7 @@ */ /* string */ +export const ATRULE = 'Atrule'; export const ATTR_SELECTOR = 'AttributeSelector'; export const CLASS_SELECTOR = 'ClassSelector'; export const COMBINATOR = 'Combinator'; @@ -13,7 +14,10 @@ export const NTH = 'Nth'; export const OPERATOR = 'Operator'; export const PS_CLASS_SELECTOR = 'PseudoClassSelector'; export const PS_ELEMENT_SELECTOR = 'PseudoElementSelector'; +export const RULE = 'Rule'; +export const SCOPE = 'Scope'; export const SELECTOR = 'Selector'; +export const SELECTOR_LIST = 'SelectorList'; export const STRING = 'String'; export const SYNTAX_ERR = 'SyntaxError'; export const TARGET_ALL = 'all'; diff --git a/src/js/utility.js b/src/js/utility.js index cb2080d..2000020 100644 --- a/src/js/utility.js +++ b/src/js/utility.js @@ -5,14 +5,16 @@ /* import */ import nwsapi from '@asamuzakjp/nwsapi'; import bidiFactory from 'bidi-js'; +import { generate, parse, walk } from 'css-tree'; import isCustomElementName from 'is-potential-custom-element-name'; /* constants */ import { - DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, DOCUMENT_POSITION_CONTAINS, + ATRULE, DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, DOCUMENT_POSITION_CONTAINS, DOCUMENT_POSITION_PRECEDING, ELEMENT_NODE, HAS_COMPOUND, KEY_INPUT_BUTTON, KEY_INPUT_EDIT, KEY_INPUT_TEXT, LOGIC_COMPLEX, LOGIC_COMPOUND, N_TH, - PSEUDO_CLASS, TARGET_LINEAL, TARGET_SELF, TEXT_NODE, TYPE_FROM, TYPE_TO + PSEUDO_CLASS, RULE, SCOPE, SELECTOR_LIST, TARGET_LINEAL, TARGET_SELF, + TEXT_NODE, TYPE_FROM, TYPE_TO } from './constant.js'; const REG_LOGIC_COMPLEX = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPLEX})`); @@ -30,6 +32,28 @@ const REG_WO_LOGICAL = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH})`); export const getType = o => Object.prototype.toString.call(o).slice(TYPE_FROM, TYPE_TO); +/** + * verify array contents + * @param {Array} arr - array + * @param {string} type - expected type, e.g. 'String' + * @throws + * @returns {Array} - verified array + */ +export const verifyArray = (arr, type) => { + if (!Array.isArray(arr)) { + throw new TypeError(`Unexpected type ${getType(arr)}`); + } + if (typeof type !== 'string') { + throw new TypeError(`Unexpected type ${getType(type)}`); + } + for (const item of arr) { + if (getType(item) !== type) { + throw new TypeError(`Unexpected type ${getType(item)}`); + } + } + return arr; +}; + /** * resolve content document, root node and tree walker, is in shadow * @param {object} node - Document, DocumentFragment, Element node @@ -640,6 +664,151 @@ export const sortNodes = (nodes = []) => { return arr; }; +/** + * concat array of nested selectors into equivalent selector + * @param {Array.>} selectors - [parents, children, ...] + * @returns {string} - selector + */ +export const concatNestedSelectors = selectors => { + if (!Array.isArray(selectors)) { + throw new TypeError(`Unexpected type ${getType(selectors)}`); + } + let selector = ''; + if (selectors.length) { + selectors = selectors.reverse(); + let child = verifyArray(selectors.shift(), 'String'); + if (child.length === 1) { + [child] = child; + } + while (selectors.length) { + const parentArr = verifyArray(selectors.shift(), 'String'); + if (!parentArr.length) { + continue; + } + let parent; + if (parentArr.length === 1) { + [parent] = parentArr; + if (!/^[>~+]/.test(parent) && /[\s>~+]/.test(parent)) { + parent = `:is(${parent})`; + } + } else { + parent = `:is(${parentArr.join(', ')})`; + } + if (selector.includes('\x26')) { + selector = selector.replace(/\x26/g, parent); + } + if (Array.isArray(child)) { + const items = []; + for (let item of child) { + if (item.includes('\x26')) { + if (/^[>~+]/.test(item)) { + item = `${parent} ${item.replace(/\x26/g, parent)} ${selector}`; + } else { + item = `${item.replace(/\x26/g, parent)} ${selector}`; + } + } else { + item = `${parent} ${item} ${selector}`; + } + items.push(item.trim()); + } + selector = items.join(', '); + } else if (selectors.length) { + selector = `${child} ${selector}`; + } else { + if (child.includes('\x26')) { + if (/^[>~+]/.test(child)) { + selector = + `${parent} ${child.replace(/\x26/g, parent)} ${selector}`; + } else { + selector = `${child.replace(/\x26/g, parent)} ${selector}`; + } + } else { + selector = `${parent} ${child} ${selector}`; + } + } + selector = selector.trim(); + if (selectors.length) { + child = parentArr.length > 1 ? parentArr : parent; + } else { + break; + } + } + selector = selector.replace(/\x26/g, ':scope').trim(); + } + return selector; +}; + +/** + * extract nested selectors from CSSRule.cssText + * @param {string} css - CSSRule.cssText + * @returns {Array.>} - array of nested selectors + */ +export const extractNestedSelectors = css => { + const ast = parse(css, { + context: 'rule' + }); + const selectors = []; + let isScoped = false; + walk(ast, { + enter: node => { + switch (node.type) { + case ATRULE: { + if (node.name === 'scope') { + isScoped = true; + } + break; + } + case SCOPE: { + const { children, type } = node.root; + const arr = []; + if (type === SELECTOR_LIST) { + for (const child of children) { + const selector = generate(child); + arr.push(selector); + } + selectors.push(arr); + } + break; + } + case RULE: { + const { children, type } = node.prelude; + const arr = []; + if (type === SELECTOR_LIST) { + let hasAmp = false; + for (const child of children) { + const selector = generate(child); + if (isScoped && !hasAmp) { + hasAmp = /\x26/.test(selector); + } + arr.push(selector); + } + if (isScoped) { + if (hasAmp) { + selectors.push(arr); + /* FIXME: + } else { + selectors = arr; + isScoped = false; + */ + } + } else { + selectors.push(arr); + } + } + } + } + }, + leave: node => { + if (node.type === ATRULE) { + if (node.name === 'scope') { + isScoped = false; + } + } + } + }); + return selectors; +}; + /** * init nwsapi * @param {object} window - Window diff --git a/test/utility.test.js b/test/utility.test.js index 5e2764d..7137e66 100644 --- a/test/utility.test.js +++ b/test/utility.test.js @@ -78,6 +78,31 @@ describe('utility functions', () => { }); }); + describe('verify array contents', () => { + const func = util.verifyArray; + + it('should throw', () => { + assert.throws(() => func(), TypeError, 'Unexpected type Undefined'); + }); + + it('should throw', () => { + assert.throws(() => func([]), TypeError, 'Unexpected type Undefined'); + }); + + it('should throw', () => { + assert.throws(() => func([1], 'String'), TypeError, + 'Unexpected type Number'); + }); + + it('should get value', () => { + const res = func(['foo', 'bar'], 'String'); + assert.deepEqual(res, [ + 'foo', + 'bar' + ], 'result'); + }); + }); + describe('resolve content document, root node and tree walker', () => { const func = util.resolveContent; @@ -1866,6 +1891,259 @@ describe('utility functions', () => { }); }); + describe('concat array of nested selectors into equivalent selector', () => { + const func = util.concatNestedSelectors; + + it('should throw', () => { + assert.throws(() => func(), TypeError, 'Unexpected type Undefined'); + }); + + it('should get empty string', () => { + const res = func([]); + assert.strictEqual(res, '', 'result'); + }); + + it('should get value', () => { + const sel = [['&'], ['.foo']]; + const res = func(sel); + assert.strictEqual(res, ':scope .foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['& > .bar']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .bar', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], [], ['& > .bar']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .bar', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['> .baz']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .baz', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo', '.bar'], ['+ .baz', '&.qux']]; + const res = func(sel); + assert.strictEqual(res, ':is(.foo, .bar) + .baz, :is(.foo, .bar).qux', + 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['& .bar & .baz & .qux']]; + const res = func(sel); + assert.strictEqual(res, '.foo .bar .foo .baz .foo .qux', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['> .bar &', '.baz &']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .bar .foo, .baz .foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['> .bar &'], ['.baz &'], ['.qux']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .bar .foo :is(.baz .foo) .qux', + 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['> .bar &', '.baz &'], ['.qux']]; + const res = func(sel); + assert.strictEqual(res, '.foo > .bar .foo .qux, .baz .foo .qux', + 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['.parent &']]; + const res = func(sel); + assert.strictEqual(res, '.parent .foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], [':not(&)']]; + const res = func(sel); + assert.strictEqual(res, ':not(.foo)', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['+ .bar + &']]; + const res = func(sel); + assert.strictEqual(res, '.foo + .bar + .foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['&']]; + const res = func(sel); + assert.strictEqual(res, '.foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['&&']]; + const res = func(sel); + assert.strictEqual(res, '.foo.foo', 'result'); + }); + + it('should get value', () => { + const sel = [['.error', '#404'], ['&:hover > .baz']]; + const res = func(sel); + assert.strictEqual(res, ':is(.error, #404):hover > .baz', 'result'); + }); + + it('should get value', () => { + const sel = [['.ancestor .el'], ['.other-ancestor &']]; + const res = func(sel); + assert.strictEqual(res, '.other-ancestor :is(.ancestor .el)', 'result'); + }); + + it('should get value', () => { + const sel = [['.foo'], ['& :is(.bar, &.baz)']]; + const res = func(sel); + assert.strictEqual(res, '.foo :is(.bar, .foo.baz)', 'result'); + }); + + it('should get value', () => { + const sel = [['figure'], ['> figcaption'], ['> p']]; + const res = func(sel); + assert.strictEqual(res, 'figure > figcaption > p', 'result'); + }); + }); + + describe('extract nested selectors from cssText', () => { + const func = util.extractNestedSelectors; + + it('should get value', () => { + const css = ` + .foo { + color: red; + & .bar, .baz & { + color: green; + & .qux &, & > .quux > &, &.corge& { + color: blue; + } + } + } + `.trim(); + const res = func(css); + assert.deepEqual(res, [ + [ + '.foo' + ], + [ + '& .bar', + '.baz &' + ], + [ + '& .qux &', + '&>.quux>&', + '&.corge&' + ] + ], 'result'); + }); + + it('should get value', () => { + const css = ` + html { + block-size: 100%; + + @layer support { + & body { + min-block-size: 100%; + } + } + } + `.trim(); + const res = func(css); + assert.deepEqual(res, [ + [ + 'html' + ], + [ + '& body' + ] + ], 'result'); + }); + + it('should get value', () => { + const css = ` + html { + @layer base { + block-size: 100%; + + @layer support { + & body { + min-block-size: 100%; + } + } + } + } + `.trim(); + const res = func(css); + assert.deepEqual(res, [ + [ + 'html' + ], + [ + '& body' + ] + ], 'result'); + }); + + // upstream issue: https://github.com/csstree/csstree/issues/268 + it.skip('should get value', () => { + const css = ` + .card { + inline-size: 40ch; + aspect-ratio: 3/4; + + @scope (&) { + :scope { + border: 1px solid white; + } + } + } + `.trim(); + const res = func(css); + assert.deepEqual(res, [ + [ + ':scope' + ] + ], 'result'); + }); + + it('should get value', () => { + const css = ` + .parent { + color: blue; + + @scope (& > .scope) to (& .limit) { + & .content { + color: red; + } + } + } + `.trim(); + const res = func(css); + assert.deepEqual(res, [ + [ + '.parent' + ], + [ + '&>.scope' + ], + [ + '& .content' + ] + ], 'result'); + }); + }); + describe('init nwsapi', () => { const func = util.initNwsapi;