Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Match selectors without using css-select where possible. #19

Merged
merged 2 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions lib/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export class CSSRule {
#specificity;
#declarations;
#isInMediaQuery;
/** @type {(function(XastElement):boolean)|undefined} */
#matcher;

/**
* @param {CSSSelector} selector
Expand All @@ -111,6 +113,7 @@ export class CSSRule {
this.#specificity = specificity;
this.#declarations = declarations;
this.#isInMediaQuery = isInMediaQuery;
this.#matcher = this.#initMatcher();
}

/**
Expand Down Expand Up @@ -197,10 +200,44 @@ export class CSSRule {
return this.#selector.hasPseudos();
}

/**
* @returns {(function(XastElement):boolean)|undefined}
*/
#initMatcher() {
const sequences = this.#selector.getSequences();
if (sequences.length > 1) {
return;
}
const simpleSelectors = sequences[0].getSelectors();
if (simpleSelectors.length > 1) {
return;
}
switch (simpleSelectors[0].type) {
case 'ClassSelector':
return (element) =>
element.attributes.class === simpleSelectors[0].name;
case 'IdSelector':
return (element) => element.attributes.id === simpleSelectors[0].name;
case 'TypeSelector':
return (element) => element.name === simpleSelectors[0].name;
}
}

isInMediaQuery() {
return this.#isInMediaQuery;
}

/**
* @param {XastElement} element
* @return {boolean|null}
*/
_matches(element) {
if (this.#matcher !== undefined) {
return this.#matcher(element);
}
return null;
}

/**
* @param {XastElement} element
* @return {boolean}
Expand Down Expand Up @@ -281,6 +318,10 @@ export class CSSSelector {
return features;
}

getSequences() {
return this.#selectorSequences;
}

getString() {
return this.#str;
}
Expand Down Expand Up @@ -374,6 +415,10 @@ export class CSSSelectorSequence {
return ids;
}

getSelectors() {
return this.#simpleSelectors;
}

/**
* @param {boolean} includePseudos
*/
Expand Down
5 changes: 5 additions & 0 deletions lib/style-css-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ class CSSRuleConcrete extends CSSRule {
*/
matches(element) {
if (!this.#compiledSelector) {
// Try to do a simple match with the rule.
const result = super._matches(element);
if (result !== null) {
return result;
}
this.#compiledSelector = CSSselect.compile(
this.getSelectorStringWithoutPseudos(),
SELECT_OPTIONS,
Expand Down
75 changes: 75 additions & 0 deletions test/lib/css.rule.match.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { CSSRule, CSSSelector, CSSSelectorSequence } from '../../lib/css.js';
import { createElement } from './testutils.js';

/**
* @typedef {import('../../lib/css.js').SimpleSelector} SimpleSelector
*/

describe('test parsing and stringifying of selectors', function () {
/** @type {{
* selectorInfo:SimpleSelector[],
* elData:{elName:string,atts:[string,string][]},
* expected:boolean|null
* }[]} */
const tests = [
{
selectorInfo: [{ type: 'ClassSelector', name: 'class1' }],
elData: { elName: 'path', atts: [] },
expected: false,
},
{
selectorInfo: [{ type: 'ClassSelector', name: 'class1' }],
elData: { elName: 'path', atts: [['class', 'class2']] },
expected: false,
},
{
selectorInfo: [{ type: 'ClassSelector', name: 'class1' }],
elData: { elName: 'path', atts: [['class', 'class1']] },
expected: true,
},
{
selectorInfo: [
{ type: 'ClassSelector', name: 'class1' },
{ type: 'IdSelector', name: 'id1' },
],
elData: { elName: 'path', atts: [['class', 'class1']] },
expected: null,
},
{
selectorInfo: [{ type: 'IdSelector', name: 'id1' }],
elData: { elName: 'path', atts: [] },
expected: false,
},
{
selectorInfo: [{ type: 'IdSelector', name: 'id1' }],
elData: { elName: 'path', atts: [['id', 'id1']] },
expected: true,
},
{
selectorInfo: [{ type: 'IdSelector', name: 'id1' }],
elData: { elName: 'path', atts: [['id', 'class1']] },
expected: false,
},
{
selectorInfo: [{ type: 'TypeSelector', name: 'path' }],
elData: { elName: 'path', atts: [] },
expected: true,
},
{
selectorInfo: [{ type: 'TypeSelector', name: 'path' }],
elData: { elName: 'circle', atts: [] },
expected: false,
},
];
const declarations = new Map();

for (const test of tests) {
const sequences = [new CSSSelectorSequence(undefined, test.selectorInfo)];
const selector = new CSSSelector(sequences);
it(`test ${selector.getString()}`, function () {
const rule = new CSSRule(selector, [0, 0, 0], declarations, false);
const element = createElement(test.elData);
expect(rule._matches(element)).toBe(test.expected);
});
}
});
4 changes: 4 additions & 0 deletions test/lib/styledata.updatereferencedids.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,19 @@ test('make sure compiled query cache is cleared correctly when style ids are cha

// Should match to begin with.
const rules = styleData.getMatchingRules(element);
const rule = rules[0];
expect(rule._matches(element)).toBe(true);
expect(rules.length).toBe(1);

// Replace the id in the rules; should no longer match.
const m = new Map();
m.set('id1', 'a');
styleData.updateReferencedIds(styleData.getReferencedIds(), m);
expect(rule._matches(element)).toBe(false);
expect(styleData.getMatchingRules(element).length).toBe(0);

// Change the id of the element; it should match again.
element.attributes.id = 'a';
expect(rule._matches(element)).toBe(true);
expect(styleData.getMatchingRules(element).length).toBe(1);
});
22 changes: 22 additions & 0 deletions test/lib/testutils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@ import { parseSvg } from '../../lib/parser.js';
import { getDocData } from '../../lib/docdata.js';
import { visit } from '../../lib/xast.js';

/**
* @param {{elName:string,atts:[string,string][]}} elData
* @returns {import('../../lib/types.js').XastElement}
*/
export function createElement(elData) {
/** @type {import('../../lib/types.js').XastRoot} */
const root = { type: 'root', children: [] };
/** @type {import('../../lib/types.js').XastElement} */
const element = {
type: 'element',
name: elData.elName,
parentNode: root,
attributes: {},
children: [],
};
for (const att of elData.atts) {
element.attributes[att[0]] = att[1];
}
root.children.push(element);
return element;
}

/**
* @param {string} fileName
*/
Expand Down
24 changes: 1 addition & 23 deletions test/lib/tools.getReferencedIds.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getReferencedIds } from '../../lib/svgo/tools.js';
import { createElement } from './testutils.js';

/**
* @typedef {{
Expand All @@ -8,29 +9,6 @@ import { getReferencedIds } from '../../lib/svgo/tools.js';
* }} TestInfo
*/

/**
*
* @param {TestInfo} test
* @returns {import('../../lib/types.js').XastElement}
*/
function createElement(test) {
/** @type {import('../../lib/types.js').XastRoot} */
const root = { type: 'root', children: [] };
/** @type {import('../../lib/types.js').XastElement} */
const element = {
type: 'element',
name: test.elName,
parentNode: root,
attributes: {},
children: [],
};
for (const att of test.atts) {
element.attributes[att[0]] = att[1];
}
root.children.push(element);
return element;
}

describe('getReferencedIds()', () => {
/**
* @type {TestInfo[]}
Expand Down