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

Refactor cleanupIDs (#1556)

I still did get how id generation works but data flow is clear now at
least.

- covered with types
- migrated to visitor plugin api
- got rid of traverse api
This commit is contained in:
Bogdan Chadkin
2021-09-05 17:35:37 +03:00
committed by GitHub
parent 107922405a
commit 298820b8d9
2 changed files with 262 additions and 244 deletions

View File

@ -1,259 +1,103 @@
'use strict';
const { traverse, traverseBreak } = require('../lib/xast.js');
const { parseName } = require('../lib/svgo/tools.js');
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { visitSkip } = require('../lib/xast.js');
const { referencesProps } = require('./_collections.js');
exports.type = 'visitor';
exports.name = 'cleanupIDs';
exports.type = 'full';
exports.active = true;
exports.description = 'removes unused IDs and minifies used';
exports.params = {
remove: true,
minify: true,
prefix: '',
preserve: [],
preservePrefixes: [],
force: false,
};
var referencesProps = new Set(require('./_collections').referencesProps),
regReferencesUrl = /\burl\(("|')?#(.+?)\1\)/,
regReferencesHref = /^#(.+?)$/,
regReferencesBegin = /(\w+)\./,
styleOrScript = ['style', 'script'],
generateIDchars = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
],
maxIDindex = generateIDchars.length - 1;
/**
* Remove unused and minify used IDs
* (only if there are no any <style> or <script>).
*
* @param {Object} item current iteration item
* @param {Object} params plugin params
*
* @author Kir Belevich
*/
exports.fn = function (root, params) {
var currentID,
currentIDstring,
IDs = new Map(),
referencesIDs = new Map(),
hasStyleOrScript = false,
preserveIDs = new Set(
Array.isArray(params.preserve)
? params.preserve
: params.preserve
? [params.preserve]
: []
),
preserveIDPrefixes = new Set(
Array.isArray(params.preservePrefixes)
? params.preservePrefixes
: params.preservePrefixes
? [params.preservePrefixes]
: []
),
idValuePrefix = '#',
idValuePostfix = '.';
traverse(root, (node) => {
if (hasStyleOrScript === true) {
return traverseBreak;
}
// quit if <style> or <script> present ('force' param prevents quitting)
if (!params.force) {
if (node.isElem(styleOrScript) && node.children.length !== 0) {
hasStyleOrScript = true;
return;
}
// Don't remove IDs if the whole SVG consists only of defs.
if (node.type === 'element' && node.name === 'svg') {
let hasDefsOnly = true;
for (const child of node.children) {
if (child.type !== 'element' || child.name !== 'defs') {
hasDefsOnly = false;
break;
}
}
if (hasDefsOnly) {
return traverseBreak;
}
}
}
// …and don't remove any ID if yes
if (node.type === 'element') {
for (const [name, value] of Object.entries(node.attributes)) {
let key;
let match;
// save IDs
if (name === 'id') {
key = value;
if (IDs.has(key)) {
delete node.attributes.id; // remove repeated id
} else {
IDs.set(key, node);
}
} else {
// save references
const { local } = parseName(name);
if (
referencesProps.has(name) &&
(match = value.match(regReferencesUrl))
) {
key = match[2]; // url() reference
} else if (
(local === 'href' && (match = value.match(regReferencesHref))) ||
(name === 'begin' && (match = value.match(regReferencesBegin)))
) {
key = match[1]; // href reference
}
if (key) {
const refs = referencesIDs.get(key) || [];
refs.push({ element: node, name, value });
referencesIDs.set(key, refs);
}
}
}
}
});
if (hasStyleOrScript) {
return root;
}
const idPreserved = (id) =>
preserveIDs.has(id) || idMatchesPrefix(preserveIDPrefixes, id);
for (const [key, refs] of referencesIDs) {
if (IDs.has(key)) {
// replace referenced IDs with the minified ones
if (params.minify && !idPreserved(key)) {
do {
currentIDstring = getIDstring(
(currentID = generateID(currentID)),
params
);
} while (idPreserved(currentIDstring));
IDs.get(key).attributes.id = currentIDstring;
for (const { element, name, value } of refs) {
element.attributes[name] = value.includes(idValuePrefix)
? value.replace(
idValuePrefix + key,
idValuePrefix + currentIDstring
)
: value.replace(
key + idValuePostfix,
currentIDstring + idValuePostfix
);
}
}
// don't remove referenced IDs
IDs.delete(key);
}
}
// remove non-referenced IDs attributes from elements
if (params.remove) {
for (var keyElem of IDs) {
if (!idPreserved(keyElem[0])) {
delete keyElem[1].attributes.id;
}
}
}
return root;
};
const regReferencesUrl = /\burl\(("|')?#(.+?)\1\)/;
const regReferencesHref = /^#(.+?)$/;
const regReferencesBegin = /(\w+)\./;
const generateIDchars = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const maxIDindex = generateIDchars.length - 1;
/**
* Check if an ID starts with any one of a list of strings.
*
* @param {Array} of prefix strings
* @param {String} current ID
* @return {Boolean} if currentID starts with one of the strings in prefixArray
* @type {(string: string, prefixes: Array<string>) => boolean}
*/
function idMatchesPrefix(prefixArray, currentID) {
if (!currentID) return false;
for (var prefix of prefixArray) if (currentID.startsWith(prefix)) return true;
const hasStringPrefix = (string, prefixes) => {
for (const prefix of prefixes) {
if (string.startsWith(prefix)) {
return true;
}
}
return false;
}
};
/**
* Generate unique minimal ID.
*
* @param {Array} [currentID] current ID
* @return {Array} generated ID array
* @type {(currentID: null | Array<number>) => Array<number>}
*/
function generateID(currentID) {
if (!currentID) return [0];
currentID[currentID.length - 1]++;
for (var i = currentID.length - 1; i > 0; i--) {
const generateID = (currentID) => {
if (currentID == null) {
return [0];
}
currentID[currentID.length - 1] += 1;
for (let i = currentID.length - 1; i > 0; i--) {
if (currentID[i] > maxIDindex) {
currentID[i] = 0;
if (currentID[i - 1] !== undefined) {
currentID[i - 1]++;
}
@ -264,15 +108,190 @@ function generateID(currentID) {
currentID.unshift(0);
}
return currentID;
}
};
/**
* Get string from generated ID array.
*
* @param {Array} arr input ID array
* @return {String} output ID string
* @type {(arr: Array<number>, prefix: string) => string}
*/
function getIDstring(arr, params) {
var str = params.prefix;
return str + arr.map((i) => generateIDchars[i]).join('');
}
const getIDstring = (arr, prefix) => {
return prefix + arr.map((i) => generateIDchars[i]).join('');
};
/**
* Remove unused and minify used IDs
* (only if there are no any <style> or <script>).
*
* @author Kir Belevich
*
* @type {import('../lib/types').Plugin<{
* remove?: boolean,
* minify?: boolean,
* prefix?: string,
* preserve?: Array<string>,
* preservePrefixes?: Array<string>,
* force?: boolean,
* }>}
*/
exports.fn = (_root, params) => {
const {
remove = true,
minify = true,
prefix = '',
preserve = [],
preservePrefixes = [],
force = false,
} = params;
const preserveIDs = new Set(
Array.isArray(preserve) ? preserve : preserve ? [preserve] : []
);
const preserveIDPrefixes = Array.isArray(preservePrefixes)
? preservePrefixes
: preservePrefixes
? [preservePrefixes]
: [];
/**
* @type {Map<string, XastElement>}
*/
const nodeById = new Map();
/**
* @type {Map<string, Array<{element: XastElement, name: string, value: string }>>}
*/
const referencesById = new Map();
let deoptimized = false;
return {
element: {
enter: (node) => {
if (force == false) {
// deoptimize if style or script elements are present
if (
(node.name === 'style' || node.name === 'script') &&
node.children.length !== 0
) {
deoptimized = true;
return;
}
// avoid removing IDs if the whole SVG consists only of defs
if (node.name === 'svg') {
let hasDefsOnly = true;
for (const child of node.children) {
if (child.type !== 'element' || child.name !== 'defs') {
hasDefsOnly = false;
break;
}
}
if (hasDefsOnly) {
return visitSkip;
}
}
}
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'id') {
// collect all ids
const id = value;
if (nodeById.has(id)) {
delete node.attributes.id; // remove repeated id
} else {
nodeById.set(id, node);
}
} else {
// collect all references
/**
* @type {null | string}
*/
let id = null;
if (referencesProps.includes(name)) {
const match = value.match(regReferencesUrl);
if (match != null) {
id = match[2]; // url() reference
}
}
if (name === 'href' || name.endsWith(':href')) {
const match = value.match(regReferencesHref);
if (match != null) {
id = match[1]; // href reference
}
}
if (name === 'begin') {
const match = value.match(regReferencesBegin);
if (match != null) {
id = match[1]; // href reference
}
}
if (id != null) {
let refs = referencesById.get(id);
if (refs == null) {
refs = [];
referencesById.set(id, refs);
}
refs.push({ element: node, name, value });
}
}
}
},
},
root: {
exit: () => {
if (deoptimized) {
return;
}
/**
* @type {(id: string) => boolean}
**/
const isIdPreserved = (id) =>
preserveIDs.has(id) || hasStringPrefix(id, preserveIDPrefixes);
/**
* @type {null | Array<number>}
*/
let currentID = null;
for (const [id, refs] of referencesById) {
const node = nodeById.get(id);
if (node != null) {
// replace referenced IDs with the minified ones
if (minify && isIdPreserved(id) === false) {
/**
* @type {null | string}
*/
let currentIDString = null;
do {
currentID = generateID(currentID);
currentIDString = getIDstring(currentID, prefix);
} while (isIdPreserved(currentIDString));
node.attributes.id = currentIDString;
for (const { element, name, value } of refs) {
if (value.includes('#')) {
// replace id in href and url()
element.attributes[name] = value.replace(
`#${id}`,
`#${currentIDString}`
);
} else {
// replace id in begin attribute
element.attributes[name] = value.replace(
`${id}.`,
`${currentIDString}.`
);
}
}
}
// keep referenced node
nodeById.delete(id);
}
}
// remove non-referenced IDs attributes from elements
if (remove) {
for (const [id, node] of nodeById) {
if (isIdPreserved(id) === false) {
delete node.attributes.id;
}
}
}
},
},
};
};