blob: 45bfe2812cc0e0621698337f46210d739b780808 [file] [log] [blame]
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { visitSkip } = require('../lib/xast.js');
const { referencesProps } = require('./_collections.js');
exports.type = 'visitor';
exports.name = 'cleanupIDs';
exports.active = true;
exports.description = 'removes unused IDs and minifies used';
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.
*
* @type {(string: string, prefixes: Array<string>) => boolean}
*/
const hasStringPrefix = (string, prefixes) => {
for (const prefix of prefixes) {
if (string.startsWith(prefix)) {
return true;
}
}
return false;
};
/**
* Generate unique minimal ID.
*
* @type {(currentID: null | Array<number>) => Array<number>}
*/
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]++;
}
}
}
if (currentID[0] > maxIDindex) {
currentID[0] = 0;
currentID.unshift(0);
}
return currentID;
};
/**
* Get string from generated ID array.
*
* @type {(arr: Array<number>, prefix: string) => string}
*/
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;
}
}
}
},
},
};
};