1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-31 07:44:22 +03:00

Add "visitor" plugins support (#1454)

Visitor is a simple pattern which helps to avoid many type checks
and provide both "perItem" and "perItemReverse" functionality without
fragmentation.

The most important case is an ability to define state which in many
plugins specified either on module level or by polluting `params`.

In this diff I added visit and detachFromParent utilities and refactored
new mergeStyles plugin with it.

Also fixed bug when cdata content is merged into "text" node which is
not always valid.
This commit is contained in:
Bogdan Chadkin
2021-03-28 11:20:17 +03:00
committed by GitHub
parent 19c77d2398
commit 27bef1a954
6 changed files with 234 additions and 60 deletions

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const { visit } = require('../xast.js');
/** /**
* Plugins engine. * Plugins engine.
* *
@ -34,6 +36,14 @@ module.exports = function (data, info, plugins) {
case 'full': case 'full':
data = full(data, info, group); data = full(data, info, group);
break; break;
case 'visitor':
for (const plugin of group) {
if (plugin.active) {
const visitor = plugin.fn(data, plugin.params, info);
visit(data, visitor);
}
}
break;
} }
} }
return data; return data;

View File

@ -51,3 +51,36 @@ const traverse = (node, fn) => {
} }
}; };
exports.traverse = traverse; exports.traverse = traverse;
const visit = (node, visitor) => {
const callbacks = visitor[node.type];
if (callbacks && callbacks.enter) {
callbacks.enter(node);
}
// visit root children
if (node.type === 'root') {
// copy children array to not loose cursor when children is spliced
for (const child of node.children) {
visit(child, visitor);
}
}
// visit element children if still attached to parent
if (node.type === 'element') {
if (node.parentNode.children.includes(node)) {
for (const child of node.children) {
visit(child, visitor);
}
}
}
if (callbacks && callbacks.exit) {
callbacks.exit(node);
}
};
exports.visit = visit;
const detachNodeFromParent = (node) => {
const parentNode = node.parentNode;
// avoid splice to not break for loops
parentNode.children = parentNode.children.filter((child) => child !== node);
};
exports.detachNodeFromParent = detachNodeFromParent;

108
lib/xast.test.js Normal file
View File

@ -0,0 +1,108 @@
'use strict';
const { expect } = require('chai');
const { visit, detachNodeFromParent } = require('./xast.js');
const getAst = () => {
const ast = {
type: 'root',
children: [
{
type: 'element',
name: 'g',
attributes: {},
children: [
{
type: 'element',
name: 'rect',
attributes: {},
children: [],
},
{
type: 'element',
name: 'circle',
attributes: {},
children: [],
},
],
},
{
type: 'element',
name: 'ellipse',
attributes: {},
children: [],
},
],
};
ast.children[0].parentNode = ast;
ast.children[0].children[0].parentNode = ast.children[0];
ast.children[0].children[1].parentNode = ast.children[0];
ast.children[1].parentNode = ast;
return ast;
};
describe('xast', () => {
it('enter into nodes', () => {
const root = getAst();
const entered = [];
visit(root, {
root: {
enter: (node) => {
entered.push(node.type);
},
},
element: {
enter: (node) => {
entered.push(`${node.type}:${node.name}`);
},
},
});
expect(entered).to.deep.equal([
'root',
'element:g',
'element:rect',
'element:circle',
'element:ellipse',
]);
});
it('exit from nodes', () => {
const root = getAst();
const exited = [];
visit(root, {
root: {
exit: (node) => {
exited.push(node.type);
},
},
element: {
exit: (node) => {
exited.push(`${node.type}:${node.name}`);
},
},
});
expect(exited).to.deep.equal([
'element:rect',
'element:circle',
'element:g',
'element:ellipse',
'root',
]);
});
it('skip entering children if node is detached', () => {
const root = getAst();
const entered = [];
visit(root, {
element: {
enter: (node) => {
entered.push(node.name);
if (node.name === 'g') {
detachNodeFromParent(node);
}
},
},
});
expect(entered).to.deep.equal(['g', 'ellipse']);
});
});

View File

@ -1,87 +1,85 @@
'use strict'; 'use strict';
const { querySelectorAll, closestByName } = require('../lib/xast.js'); const { closestByName, detachNodeFromParent } = require('../lib/xast.js');
const { getCssStr, setCssStr } = require('../lib/css-tools'); const JSAPI = require('../lib/svgo/jsAPI.js');
exports.type = 'full'; exports.type = 'visitor';
exports.active = true; exports.active = true;
exports.description = 'merge multiple style elements into one'; exports.description = 'merge multiple style elements into one';
/** /**
* Merge multiple style elements into one. * Merge multiple style elements into one.
* *
* @param {Object} document document element
*
* @author strarsis <strarsis@gmail.com> * @author strarsis <strarsis@gmail.com>
*/ */
exports.fn = function (document) { exports.fn = () => {
// collect <style/>s with valid type attribute (preserve order) let firstStyleElement = null;
const styleElements = querySelectorAll(document, 'style'); let collectedStyles = '';
let styleContentType = 'text';
// no <styles/>s, nothing to do const enterElement = (node) => {
if (styleElements.length === 0) { // collect style elements
return document; if (node.name !== 'style') {
} return;
}
const styles = []; // skip <style> with invalid type attribute
for (const styleElement of styleElements) {
if ( if (
styleElement.attributes.type && node.attributes.type != null &&
styleElement.attributes.type !== 'text/css' node.attributes.type !== '' &&
node.attributes.type !== 'text/css'
) { ) {
// skip <style> with invalid type attribute return;
continue;
} }
if (closestByName(styleElement, 'foreignObject')) { // skip <foreignObject> content
// skip <foreignObject> content if (closestByName(node, 'foreignObject')) {
continue; return;
} }
const cssString = getCssStr(styleElement); // extract style element content
let css = '';
for (const child of node.children) {
if (child.type === 'text') {
css += child.value;
}
if (child.type === 'cdata') {
styleContentType = 'cdata';
css += child.value;
}
}
styles.push({ // remove empty style elements
styleElement: styleElement, if (css.trim().length === 0) {
detachNodeFromParent(node);
return;
}
mq: styleElement.attributes.media, // collect css and wrap with media query if present in attribute
cssStr: cssString, if (node.attributes.media == null) {
}); collectedStyles += css;
}
const collectedStyles = [];
for (let styleNo = 0; styleNo < styles.length; styleNo += 1) {
const style = styles[styleNo];
if (style.mq) {
const wrappedStyles = `@media ${style.mq}{${style.cssStr}}`;
collectedStyles.push(wrappedStyles);
} else { } else {
collectedStyles.push(style.cssStr); collectedStyles += `@media ${node.attributes.media}{${css}}`;
delete node.attributes.media;
} }
// remove all processed style elements except the first one // combine collected styles in the first style element
if (styleNo > 0) { if (firstStyleElement == null) {
removeFromParent(style.styleElement); firstStyleElement = node;
} else {
detachNodeFromParent(node);
firstStyleElement.children = [
new JSAPI(
{ type: styleContentType, value: collectedStyles },
firstStyleElement
),
];
} }
} };
const collectedStylesString = collectedStyles.join('');
// combine collected styles in the first style element return {
const firstStyle = styles[0]; element: {
delete firstStyle.styleElement.attributes.media; // remove media mq attribute as CSS media queries are used enter: enterElement,
if (collectedStylesString.trim().length > 0) { },
setCssStr(firstStyle.styleElement, collectedStylesString); };
} else {
removeFromParent(firstStyle.styleElement);
}
return document;
}; };
function removeFromParent(element) {
const parentElement = element.parentNode;
return parentElement.children.splice(
parentElement.children.indexOf(element),
1
);
}

View File

@ -0,0 +1,24 @@
Convert content to cdata if any style element contains cdata
===
<svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<style>
.st0 { fill: yellow; }
</style>
<style>
<![CDATA[
.st1 { fill: red; }
]]>
</style>
</svg>
@@@
<svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<style>
<![CDATA[.st0 { fill: yellow; }
.st1 { fill: red; }
]]>
</style>
</svg>

View File

@ -36,6 +36,7 @@
"plugins/convertStyleToAttrs.js", "plugins/convertStyleToAttrs.js",
"plugins/convertTransform.js", "plugins/convertTransform.js",
"plugins/mergePaths.js", "plugins/mergePaths.js",
"plugins/mergeStyles.js",
"plugins/moveElemsAttrsToGroup.js", "plugins/moveElemsAttrsToGroup.js",
"plugins/moveGroupAttrsToElems.js", "plugins/moveGroupAttrsToElems.js",
"plugins/plugins.js", "plugins/plugins.js",