1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-29 20:21:14 +03:00

Refactor prefixIds (#1561)

Ref https://github.com/svg/svgo/issues/1499

- migrated to visitor plugin api
- covered with tsdoc
- made the plugin idempotent as requested a few times
  Now even manually running svgo a few times will not duplicate
  prefix in ids and classes
- run each plugin test twice to see which plugin need to run many times
  ideally idempotent plugins will allow to get rid of multipass option in v3
This commit is contained in:
Bogdan Chadkin
2021-09-13 16:16:38 +03:00
committed by GitHub
parent 23c7f48130
commit 3d22a5b23d
9 changed files with 286 additions and 318 deletions

View File

@ -1,5 +1,9 @@
'use strict';
/**
* @typedef {import('../lib/types').Plugin} Plugin
*/
const { optimize } = require('./svgo.js');
test('allow to setup default preset', () => {
@ -324,3 +328,28 @@ test('provides legacy error message', () => {
4 |
`);
});
test('multipass option should trigger plugins multiple times', () => {
const svg = `<svg id="abcdefghijklmnopqrstuvwxyz"></svg>`;
const list = [];
/**
* @type {Plugin<void>}
*/
const testPlugin = {
type: 'visitor',
name: 'testPlugin',
fn: (_root, _params, info) => {
list.push(info.multipassCount);
return {
element: {
enter: (node) => {
node.attributes.id = node.attributes.id.slice(1);
},
},
};
},
};
const { data } = optimize(svg, { multipass: true, plugins: [testPlugin] });
expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(data).toEqual(`<svg id="klmnopqrstuvwxyz"/>`);
});

View File

@ -71,7 +71,16 @@ export type Visitor = {
root?: VisitorRoot;
};
export type Plugin<Params> = (root: XastRoot, params: Params) => null | Visitor;
export type PluginInfo = {
path?: string;
multipassCount: number;
};
export type Plugin<Params> = (
root: XastRoot,
params: Params,
info: PluginInfo
) => null | Visitor;
export type Specificity = [number, number, number, number];

View File

@ -1,167 +1,25 @@
'use strict';
const csstree = require('css-tree');
const { referencesProps } = require('./_collections.js');
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').PluginInfo} PluginInfo
*/
exports.type = 'visitor';
exports.name = 'prefixIds';
exports.type = 'perItem';
exports.active = false;
exports.params = {
delim: '__',
prefixIds: true,
prefixClassNames: true,
};
exports.description = 'prefix IDs';
var csstree = require('css-tree'),
collections = require('./_collections.js'),
referencesProps = collections.referencesProps,
rxId = /^#(.*)$/, // regular expression for matching an ID + extracing its name
addPrefix = null;
const unquote = (string) => {
const first = string.charAt(0);
if (first === "'" || first === '"') {
if (first === string.charAt(string.length - 1)) {
return string.slice(1, -1);
}
}
return string;
};
// Escapes a string for being used as ID
var escapeIdentifierName = function (str) {
return str.replace(/[. ]/g, '_');
};
// Matches an #ID value, captures the ID name
var matchId = function (urlVal) {
var idUrlMatches = urlVal.match(rxId);
if (idUrlMatches === null) {
return false;
}
return idUrlMatches[1];
};
// Matches an url(...) value, captures the URL
var matchUrl = function (val) {
var urlMatches = /url\((.*?)\)/gi.exec(val);
if (urlMatches === null) {
return false;
}
return urlMatches[1];
};
// prefixes an #ID
var prefixId = function (val) {
var idName = matchId(val);
if (!idName) {
return false;
}
return '#' + addPrefix(idName);
};
// prefixes a class attribute value
const addPrefixToClassAttr = (element, name) => {
if (
element.attributes[name] == null ||
element.attributes[name].length === 0
) {
return;
}
element.attributes[name] = element.attributes[name]
.split(/\s+/)
.map(addPrefix)
.join(' ');
};
// prefixes an ID attribute value
const addPrefixToIdAttr = (element, name) => {
if (
element.attributes[name] == null ||
element.attributes[name].length === 0
) {
return;
}
element.attributes[name] = addPrefix(element.attributes[name]);
};
// prefixes a href attribute value
const addPrefixToHrefAttr = (element, name) => {
if (
element.attributes[name] == null ||
element.attributes[name].length === 0
) {
return;
}
const idPrefixed = prefixId(element.attributes[name]);
if (!idPrefixed) {
return;
}
element.attributes[name] = idPrefixed;
};
// prefixes an URL attribute value
const addPrefixToUrlAttr = (element, name) => {
if (
element.attributes[name] == null ||
element.attributes[name].length === 0
) {
return;
}
// url(...) in value
const urlVal = matchUrl(element.attributes[name]);
if (!urlVal) {
return;
}
const idPrefixed = prefixId(urlVal);
if (!idPrefixed) {
return;
}
element.attributes[name] = 'url(' + idPrefixed + ')';
};
// prefixes begin/end attribute value
const addPrefixToBeginEndAttr = (element, name) => {
if (
element.attributes[name] == null ||
element.attributes[name].length === 0
) {
return;
}
const parts = element.attributes[name].split('; ').map((val) => {
val = val.trim();
if (val.endsWith('.end') || val.endsWith('.start')) {
const [id, postfix] = val.split('.');
let idPrefixed = prefixId(`#${id}`);
if (!idPrefixed) {
return val;
}
idPrefixed = idPrefixed.slice(1);
return `${idPrefixed}.${postfix}`;
} else {
return val;
}
});
element.attributes[name] = parts.join('; ');
};
/**
* extract basename from path
* @type {(path: string) => string}
*/
const getBasename = (path) => {
// extract everything after latest slash or backslash
const matched = path.match(/[/\\]([^/\\]+)$/);
const matched = path.match(/[/\\]?([^/\\]+)$/);
if (matched) {
return matched[1];
}
@ -169,132 +27,214 @@ const getBasename = (path) => {
};
/**
* Prefixes identifiers
*
* @param {Object} node node
* @param {Object} opts plugin params
* @param {Object} extra plugin extra information
*
* @author strarsis <strarsis@gmail.com>
* escapes a string for being used as ID
* @type {(string: string) => string}
*/
exports.fn = function (node, opts, extra) {
// skip subsequent passes when multipass is used
if (extra.multipassCount && extra.multipassCount > 0) {
return;
}
// prefix, from file name or option
var prefix = 'prefix';
if (opts.prefix) {
if (typeof opts.prefix === 'function') {
prefix = opts.prefix(node, extra);
} else {
prefix = opts.prefix;
}
} else if (opts.prefix === false) {
prefix = false;
} else if (extra && extra.path && extra.path.length > 0) {
var filename = getBasename(extra.path);
prefix = filename;
}
// prefixes a normal value
addPrefix = function (name) {
if (prefix === false) {
return escapeIdentifierName(name);
}
return escapeIdentifierName(prefix + opts.delim + name);
const escapeIdentifierName = (str) => {
return str.replace(/[. ]/g, '_');
};
// <style/> property values
/**
* @type {(string: string) => string}
*/
const unquote = (string) => {
if (
(string.startsWith('"') && string.endsWith('"')) ||
(string.startsWith("'") && string.endsWith("'"))
) {
return string.slice(1, -1);
}
return string;
};
if (node.type === 'element' && node.name === 'style') {
/**
* prefix an ID
* @type {(prefix: string, name: string) => string}
*/
const prefixId = (prefix, value) => {
if (value.startsWith(prefix)) {
return value;
}
return prefix + value;
};
/**
* prefix an #ID
* @type {(prefix: string, name: string) => string | null}
*/
const prefixReference = (prefix, value) => {
if (value.startsWith('#')) {
return '#' + prefixId(prefix, value.slice(1));
}
return null;
};
/**
* Prefixes identifiers
*
* @author strarsis <strarsis@gmail.com>
*
* @type {import('../lib/types').Plugin<{
* prefix?: boolean | string | ((node: XastElement, info: PluginInfo) => string),
* delim?: string,
* prefixIds?: boolean,
* prefixClassNames?: boolean,
* }>}
*/
exports.fn = (_root, params, info) => {
const { delim = '__', prefixIds = true, prefixClassNames = true } = params;
return {
element: {
enter: (node) => {
/**
* prefix, from file name or option
* @type {string}
*/
let prefix = 'prefix' + delim;
if (typeof params.prefix === 'function') {
prefix = params.prefix(node, info) + delim;
} else if (typeof params.prefix === 'string') {
prefix = params.prefix + delim;
} else if (params.prefix === false) {
prefix = '';
} else if (info.path != null && info.path.length > 0) {
prefix = escapeIdentifierName(getBasename(info.path)) + delim;
}
// prefix id/class selectors and url() references in styles
if (node.name === 'style') {
// skip empty <style/> elements
if (node.children.length === 0) {
// skip empty <style/>s
return;
}
var cssStr = '';
if (node.children[0].type === 'text' || node.children[0].type === 'cdata') {
cssStr = node.children[0].value;
// parse styles
let cssText = '';
if (
node.children[0].type === 'text' ||
node.children[0].type === 'cdata'
) {
cssText = node.children[0].value;
}
var cssAst = {};
/**
* @type {null | csstree.CssNode}
*/
let cssAst = null;
try {
cssAst = csstree.parse(cssStr, {
cssAst = csstree.parse(cssText, {
parseValue: true,
parseCustomProperty: false,
});
} catch (parseError) {
console.warn(
'Warning: Parse error of styles of <style/> element, skipped. Error details: ' +
parseError
);
} catch {
return;
}
var idPrefixed = '';
csstree.walk(cssAst, function (node) {
// #ID, .class
csstree.walk(cssAst, (node) => {
// #ID, .class selectors
if (
((opts.prefixIds && node.type === 'IdSelector') ||
(opts.prefixClassNames && node.type === 'ClassSelector')) &&
node.name
(prefixIds && node.type === 'IdSelector') ||
(prefixClassNames && node.type === 'ClassSelector')
) {
node.name = addPrefix(node.name);
node.name = prefixId(prefix, node.name);
return;
}
// url(...) in value
// url(...) references
if (
node.type === 'Url' &&
node.value.value &&
node.value.value.length > 0
) {
idPrefixed = prefixId(unquote(node.value.value));
if (!idPrefixed) {
return;
const prefixed = prefixReference(
prefix,
unquote(node.value.value)
);
if (prefixed != null) {
node.value.value = prefixed;
}
node.value.value = idPrefixed;
}
});
// update <style>s
// update styles
if (
node.children[0].type === 'text' ||
node.children[0].type === 'cdata'
) {
node.children[0].value = csstree.generate(cssAst);
}
return;
}
// element attributes
if (node.type !== 'element') {
return;
// prefix an ID attribute value
if (
prefixIds &&
node.attributes.id != null &&
node.attributes.id.length !== 0
) {
node.attributes.id = prefixId(prefix, node.attributes.id);
}
// Nodes
if (opts.prefixIds) {
// ID
addPrefixToIdAttr(node, 'id');
// prefix a class attribute value
if (
prefixClassNames &&
node.attributes.class != null &&
node.attributes.class.length !== 0
) {
node.attributes.class = node.attributes.class
.split(/\s+/)
.map((name) => prefixId(prefix, name))
.join(' ');
}
if (opts.prefixClassNames) {
// Class
addPrefixToClassAttr(node, 'class');
// prefix a href attribute value
// xlink:href is deprecated, must be still supported
for (const name of ['href', 'xlink:href']) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
const prefixed = prefixReference(prefix, node.attributes[name]);
if (prefixed != null) {
node.attributes[name] = prefixed;
}
}
}
// References
// href
addPrefixToHrefAttr(node, 'href');
// (xlink:)href (deprecated, must be still supported)
addPrefixToHrefAttr(node, 'xlink:href');
// (referenceable) properties
for (var referencesProp of referencesProps) {
addPrefixToUrlAttr(node, referencesProp);
// prefix an URL attribute value
for (const name of referencesProps) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
// extract id reference from url(...) value
const matches = /url\((.*?)\)/gi.exec(node.attributes[name]);
if (matches != null) {
const value = matches[1];
const prefixed = prefixReference(prefix, value);
if (prefixed != null) {
node.attributes[name] = `url(${prefixed})`;
}
}
}
}
addPrefixToBeginEndAttr(node, 'begin');
addPrefixToBeginEndAttr(node, 'end');
// prefix begin/end attribute value
for (const name of ['begin', 'end']) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
const parts = node.attributes[name].split(/\s*;\s+/).map((val) => {
if (val.endsWith('.end') || val.endsWith('.start')) {
const [id, postfix] = val.split('.');
return `${prefixId(prefix, id)}.${postfix}`;
}
return val;
});
node.attributes[name] = parts.join('; ');
}
}
},
},
};
};

View File

@ -30,14 +30,21 @@ describe('plugins tests', function () {
name,
params: params ? JSON.parse(params) : {},
};
const result = optimize(original, {
let lastResultData = original;
// test plugins idempotence
const exclude = ['addAttributesToSVGElement', 'convertTransform'];
const multipass = exclude.includes(name) ? 1 : 2;
for (let i = 0; i < multipass; i += 1) {
const result = optimize(lastResultData, {
path: file,
plugins: [plugin],
js2svg: { pretty: true },
});
lastResultData = result.data;
expect(result.error).not.toEqual(expect.anything());
//FIXME: results.data has a '\n' at the end while it should not
expect(normalize(result.data)).toEqual(should);
}
});
});
}

View File

@ -0,0 +1,24 @@
'use strict';
const { optimize } = require('../../lib/svgo.js');
test('should extract prefix from path basename', () => {
const svg = `<svg id="my-id"></svg>`;
expect(
optimize(svg, {
plugins: ['prefixIds'],
}).data
).toEqual(`<svg id="prefix__my-id"/>`);
expect(
optimize(svg, {
plugins: ['prefixIds'],
path: 'input.svg',
}).data
).toEqual(`<svg id="input_svg__my-id"/>`);
expect(
optimize(svg, {
plugins: ['prefixIds'],
path: 'path/to/input.svg',
}).data
).toEqual(`<svg id="input_svg__my-id"/>`);
});

View File

@ -26,21 +26,6 @@ describe('svgo', () => {
});
expect(normalize(result.data)).toEqual(expected);
});
it('should run multiple times', async () => {
const [original, expected] = await parseFixture('multipass.svg');
const result = optimize(original, {
multipass: true,
});
expect(normalize(result.data)).toEqual(expected);
});
it('should pass multipass count to plugins', async () => {
const [original, expected] = await parseFixture('multipass-prefix-ids.svg');
const result = optimize(original, {
multipass: true,
plugins: ['preset-default', 'prefixIds'],
});
expect(normalize(result.data)).toEqual(expected);
});
it('should handle plugins order properly', async () => {
const [original, expected] = await parseFixture('plugins-order.svg');
const result = optimize(original, { input: 'file', path: 'input.svg' });

View File

@ -1,7 +0,0 @@
<svg width="120" height="120" xmlns="http://www.w3.org/2000/svg">
<rect class="test" x="10" y="10" width="100" height="100"/>
</svg>
@@@
<svg width="120" height="120" xmlns="http://www.w3.org/2000/svg"><path class="prefix__test" d="M10 10h100v100H10z"/></svg>

Before

Width:  |  Height:  |  Size: 264 B

View File

@ -1,18 +0,0 @@
<!-- https://github.com/svg/svgo/pull/258#issue-23706813 -->
<svg xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-39, -239)">
<g id="test" transform="translate(39, 37)">
<g>
<rect id="Rectangle-1183" fill="#D4D4D4" x="8" y="223" width="7" height="19"></rect>
<rect id="Rectangle-1183" fill="#D4D4D4" x="4" y="227" width="42" height="3"></rect>
<rect id="Rectangle-1183" fill="#D0011B" x="23" y="227" width="6" height="3"></rect>
<rect id="Rectangle-1183" fill="#D0011B" x="35" y="227" width="6" height="3"></rect>
<rect id="Rectangle-1183" fill="#D0011B" x="11" y="227" width="6" height="3"></rect>
</g>
</g>
</g>
</svg>
@@@
<svg xmlns="http://www.w3.org/2000/svg"><path fill="#D4D4D4" d="M8 21h7v19H8z"/><path fill="#D4D4D4" d="M4 25h42v3H4z"/><path fill="#D0011B" d="M23 25h6v3h-6zm12 0h6v3h-6zm-24 0h6v3h-6z"/></svg>

Before

Width:  |  Height:  |  Size: 899 B

View File

@ -25,7 +25,6 @@
"plugins/moveElemsAttrsToGroup.js",
"plugins/moveGroupAttrsToElems.js",
"plugins/plugins.js",
"plugins/prefixIds.js",
"plugins/removeDimensions.js",
"plugins/removeEmptyAttrs.js",
"plugins/removeNonInheritableGroupAttrs.js",