You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

287 lines
6.7 KiB

'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { visitSkip } = require('../lib/xast.js');
const { referencesProps } = require('./_collections.js');
exports.name = 'cleanupIds';
exports.description = 'removes unused IDs and minifies used';
const regReferencesUrl = /\burl\((["'])?#(.+?)\1\)/;
const regReferencesHref = /^#(.+?)$/;
const regReferencesBegin = /(\D+)\./;
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>) => string}
*/
const getIdString = (arr) => {
return 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('./plugins-types').Plugin<'cleanupIds'>}
*/
exports.fn = (_root, params) => {
const {
remove = true,
minify = true,
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);
} 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;
}
}
}
},
},
};
};