298 lines
		
	
	
		
			7.0 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			298 lines
		
	
	
		
			7.0 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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;
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|       },
 | |
|     },
 | |
|   };
 | |
| };
 |