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

cleanupIds: add support for changing ids in <style> elements #17

Merged
merged 14 commits into from
Sep 26, 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
64 changes: 64 additions & 0 deletions lib/css-tools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @typedef {import('./types.js').XastElement} XastElement
* @typedef {XastElement&{style?:string,declarations?:import('./types.js').CSSDeclarationMap}} XastElementExtended
*/

import { _parseStyleDeclarations } from './style-css-tree-tools.js';

const REGEX_FN = /url\([^#]/;

/**
* @param {XastElementExtended} element
* @returns {import('./types.js').CSSDeclarationMap|undefined}
*/
export function getStyleDeclarations(element) {
const style = element.attributes.style;
if (style === undefined || style === '') {
return;
}
if (element.style !== style) {
element.style = style;
element.declarations = parseStyleDeclarations(style);
}
// Copy cached map in case it is changed by caller.
return new Map(element.declarations);
}

/**
* @param {string} css
*/
export function _isStyleComplex(css) {
return REGEX_FN.test(css);
}

/**
* @param {string|undefined} css
* @returns {Map<string,import('./types.js').CSSPropertyValue>}
*/
export function parseStyleDeclarations(css) {
/** @type {Map<string,import('./types.js').CSSPropertyValue>} */
const declarations = new Map();
if (css === undefined) {
return declarations;
}

if (_isStyleComplex(css)) {
// There's a function in the declaration; use the less efficient low-level implementation.
return _parseStyleDeclarations(css);
}

const decList = css.split(';');
for (const declaration of decList) {
if (declaration) {
const pv = declaration.split(':');
if (pv.length === 2) {
const dec = pv[1].trim();
const value = dec.endsWith('!important')
? { value: dec.substring(0, dec.length - 10).trim(), important: true }
: { value: dec, important: false };
declarations.set(pv[0].trim(), value);
}
}
}
return declarations;
}
230 changes: 159 additions & 71 deletions lib/css.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// eslint-disable-next-line no-unused-vars
import { _parseStyleDeclarations } from './style-css-tree-tools.js';
import {
getReferencedIdInStyleProperty,
updateReferencedDeclarationIds,
} from './svgo/tools.js';

/**
* @typedef {import('./types.js').XastElement} XastElement
* @typedef {XastElement&{style?:string,declarations?:import('./types.js').CSSDeclarationMap}} XastElementExtended
* @typedef {import('./types.js').CSSFeatures} CSSFeatures
* @typedef{{type:'AttributeSelector',name:string,matcher:string|null,value:string|null}} AttributeSelector
* @typedef{{type:'AttributeSelector',name:string,matcher:string|null,value:string|null,flags:string|null}} AttributeSelector
* @typedef{{type:'ClassSelector',name:string}} ClassSelector
* @typedef{{type:'IdSelector',name:string}} IdSelector
* @typedef{{type:'PseudoClassSelector',name:string}} PseudoClassSelector
Expand All @@ -11,10 +17,6 @@
* @typedef{AttributeSelector|ClassSelector|IdSelector|PseudoClassSelector|PseudoElementSelector|TypeSelector} SimpleSelector
*/

import { _parseStyleDeclarations } from './style-css-tree-tools.js';

const REGEX_FN = /url\([^#]/;

export class CSSRuleSet {
#atRule;
/** @type {CSSRule[]} */
Expand Down Expand Up @@ -101,7 +103,7 @@ export class CSSRule {
/**
* @param {CSSSelector} selector
* @param {[number,number,number]} specificity
* @param {Map<string,{value:string,important?:boolean}>} declarations
* @param {import('./types.js').CSSDeclarationMap} declarations
* @param {boolean} isInMediaQuery
*/
constructor(selector, specificity, declarations, isInMediaQuery) {
Expand All @@ -118,6 +120,34 @@ export class CSSRule {
this.#selector.addReferencedClasses(classes);
}

/**
* @param {Map<string,CSSRule[]>} referencedIds
*/
addReferencedIds(referencedIds) {
/**
* @param {string} id
* @param {CSSRule} rule
*/
function addReference(id, rule) {
let rules = referencedIds.get(id);
if (!rules) {
rules = [];
referencedIds.set(id, rules);
}
rules.push(rule);
}
const selectorIds = this.#selector.getReferencedIds();
for (const id of selectorIds) {
addReference(id, this);
}
for (const propValue of this.#declarations.values()) {
const idInfo = getReferencedIdInStyleProperty(propValue.value);
if (idInfo) {
addReference(idInfo.id, this);
}
}
}

getDeclarations() {
return this.#declarations;
}
Expand Down Expand Up @@ -179,23 +209,34 @@ export class CSSRule {
matches(element) {
throw new Error();
}

/**
* @param {Map<string,string>} idMap
*/
updateReferencedIds(idMap) {
this.#selector.updateReferencedIds(idMap);
updateReferencedDeclarationIds(this.#declarations, idMap);
}
}

export class CSSSelector {
#selectorSequences;
/** @type {string} */
#str;
#strWithoutPseudos;

/**
* @param {CSSSelectorSequence[]} selectorSequences
* @param {string} str
* @param {string} [strWithoutPseudos]
*/
constructor(selectorSequences, str, strWithoutPseudos) {
constructor(selectorSequences) {
this.#selectorSequences = selectorSequences;
this.#str = str;
this.#strWithoutPseudos =
strWithoutPseudos === '' ? '*' : strWithoutPseudos;
this.#str = this.#generateSelectorString();
if (this.#str.includes(':')) {
this.#strWithoutPseudos = this.#generateSelectorString(false);
if (this.#strWithoutPseudos === '') {
this.#strWithoutPseudos = '*';
}
}
}

/**
Expand All @@ -207,6 +248,24 @@ export class CSSSelector {
}
}

/**
* @param {boolean} [includePseudos]
* @returns {string}
*/
#generateSelectorString(includePseudos = true) {
return this.#selectorSequences.reduce((s, seq) => {
return s + seq.getString(includePseudos);
}, '');
}

getReferencedIds() {
const ids = [];
for (const seq of this.#selectorSequences) {
ids.push(...seq.getReferencedIds());
}
return ids;
}

/**
* @returns {Set<CSSFeatures>}
*/
Expand Down Expand Up @@ -242,18 +301,31 @@ export class CSSSelector {
hasPseudos() {
return this.#strWithoutPseudos !== undefined;
}

/**
* @param {Map<string,string>} idMap
*/
updateReferencedIds(idMap) {
for (const selectorSequence of this.#selectorSequences) {
selectorSequence.updateReferencedIds(idMap);
}
this.#str = this.#generateSelectorString();
if (this.hasPseudos()) {
this.#strWithoutPseudos = this.#generateSelectorString(false);
}
}
}

export class CSSSelectorSequence {
// #comparator;
#comparator;
#simpleSelectors;

/**
* @param {string|undefined} comparator
* @param {SimpleSelector[]} simpleSelectors
*/
constructor(comparator, simpleSelectors) {
// this.#comparator = comparator;
this.#comparator = comparator;
this.#simpleSelectors = simpleSelectors;
}

Expand Down Expand Up @@ -285,10 +357,64 @@ export class CSSSelectorSequence {
switch (selector.type) {
case 'ClassSelector':
classes.add(selector.name);
break;
}
}
}

getReferencedIds() {
const ids = [];
for (const selector of this.#simpleSelectors) {
switch (selector.type) {
case 'IdSelector':
ids.push(selector.name);
break;
}
}
return ids;
}

/**
* @param {boolean} includePseudos
*/
getString(includePseudos) {
let s = this.#comparator === undefined ? '' : this.#comparator;
for (const selector of this.#simpleSelectors) {
switch (selector.type) {
case 'AttributeSelector':
if (selector.matcher) {
s += `[${selector.name}${selector.matcher}"${selector.value}"${selector.flags ? selector.flags : ''}]`;
} else {
s += `[${selector.name}]`;
}
break;
case 'ClassSelector':
s += '.' + selector.name;
break;
case 'IdSelector':
s += '#' + selector.name;
break;
case 'PseudoClassSelector':
if (includePseudos) {
s += ':' + selector.name;
}
break;
case 'PseudoElementSelector':
if (includePseudos) {
s += '::' + selector.name;
}
break;
case 'TypeSelector':
s += selector.name;
break;
default:
// @ts-ignore - in case new types are introduced
throw new Error(selector.type);
}
}
return s;
}

/**
* @param {string} [attName]
*/
Expand All @@ -300,6 +426,24 @@ export class CSSSelectorSequence {
}
return false;
}

/**
* @param {Map<string,string>} idMap
*/
updateReferencedIds(idMap) {
for (const selector of this.#simpleSelectors) {
switch (selector.type) {
case 'IdSelector':
{
const newId = idMap.get(selector.name);
if (newId) {
selector.name = newId;
}
}
break;
}
}
}
}

export class CSSParseError extends Error {
Expand All @@ -311,62 +455,6 @@ export class CSSParseError extends Error {
}
}

/**
* @param {XastElementExtended} element
* @returns {import('./types.js').CSSDeclarationMap|undefined}
*/
export function getStyleDeclarations(element) {
const style = element.attributes.style;
if (style === undefined || style === '') {
return;
}
if (element.style !== style) {
element.style = style;
element.declarations = parseStyleDeclarations(style);
}
// Copy cached map in case it is changed by caller.
return new Map(element.declarations);
}

/**
* @param {string} css
*/
export function _isStyleComplex(css) {
return REGEX_FN.test(css);
}

/**
* @param {string|undefined} css
* @returns {Map<string,import('./types.js').CSSPropertyValue>}
*/
export function parseStyleDeclarations(css) {
/** @type {Map<string,import('./types.js').CSSPropertyValue>} */
const declarations = new Map();
if (css === undefined) {
return declarations;
}

if (_isStyleComplex(css)) {
// There's a function in the declaration; use the less efficient low-level implementation.
return _parseStyleDeclarations(css);
}

const decList = css.split(';');
for (const declaration of decList) {
if (declaration) {
const pv = declaration.split(':');
if (pv.length === 2) {
const dec = pv[1].trim();
const value = dec.endsWith('!important')
? { value: dec.substring(0, dec.length - 10).trim(), important: true }
: { value: dec, important: false };
declarations.set(pv[0].trim(), value);
}
}
}
return declarations;
}

/**
* @param {import('../lib/types.js').XastElement} element
* @param {Map<string,{value:string,important?:boolean}>} properties
Expand Down
Loading