Skip to content

Commit

Permalink
cleanupIds: add support for changing ids in <style> elements (#17)
Browse files Browse the repository at this point in the history
* Add support for changing ids in <style> elements
  • Loading branch information
johnkenny54 authored Sep 26, 2024
1 parent e147d9d commit 7a3ac84
Show file tree
Hide file tree
Showing 35 changed files with 1,139 additions and 478 deletions.
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

0 comments on commit 7a3ac84

Please sign in to comment.