cleanupIDs.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. 'use strict';
  2. /**
  3. * @typedef {import('../lib/types').XastElement} XastElement
  4. */
  5. const { visitSkip } = require('../lib/xast.js');
  6. const { referencesProps } = require('./_collections.js');
  7. exports.type = 'visitor';
  8. exports.name = 'cleanupIDs';
  9. exports.active = true;
  10. exports.description = 'removes unused IDs and minifies used';
  11. const regReferencesUrl = /\burl\(("|')?#(.+?)\1\)/;
  12. const regReferencesHref = /^#(.+?)$/;
  13. const regReferencesBegin = /(\w+)\./;
  14. const generateIDchars = [
  15. 'a',
  16. 'b',
  17. 'c',
  18. 'd',
  19. 'e',
  20. 'f',
  21. 'g',
  22. 'h',
  23. 'i',
  24. 'j',
  25. 'k',
  26. 'l',
  27. 'm',
  28. 'n',
  29. 'o',
  30. 'p',
  31. 'q',
  32. 'r',
  33. 's',
  34. 't',
  35. 'u',
  36. 'v',
  37. 'w',
  38. 'x',
  39. 'y',
  40. 'z',
  41. 'A',
  42. 'B',
  43. 'C',
  44. 'D',
  45. 'E',
  46. 'F',
  47. 'G',
  48. 'H',
  49. 'I',
  50. 'J',
  51. 'K',
  52. 'L',
  53. 'M',
  54. 'N',
  55. 'O',
  56. 'P',
  57. 'Q',
  58. 'R',
  59. 'S',
  60. 'T',
  61. 'U',
  62. 'V',
  63. 'W',
  64. 'X',
  65. 'Y',
  66. 'Z',
  67. ];
  68. const maxIDindex = generateIDchars.length - 1;
  69. /**
  70. * Check if an ID starts with any one of a list of strings.
  71. *
  72. * @type {(string: string, prefixes: Array<string>) => boolean}
  73. */
  74. const hasStringPrefix = (string, prefixes) => {
  75. for (const prefix of prefixes) {
  76. if (string.startsWith(prefix)) {
  77. return true;
  78. }
  79. }
  80. return false;
  81. };
  82. /**
  83. * Generate unique minimal ID.
  84. *
  85. * @type {(currentID: null | Array<number>) => Array<number>}
  86. */
  87. const generateID = (currentID) => {
  88. if (currentID == null) {
  89. return [0];
  90. }
  91. currentID[currentID.length - 1] += 1;
  92. for (let i = currentID.length - 1; i > 0; i--) {
  93. if (currentID[i] > maxIDindex) {
  94. currentID[i] = 0;
  95. if (currentID[i - 1] !== undefined) {
  96. currentID[i - 1]++;
  97. }
  98. }
  99. }
  100. if (currentID[0] > maxIDindex) {
  101. currentID[0] = 0;
  102. currentID.unshift(0);
  103. }
  104. return currentID;
  105. };
  106. /**
  107. * Get string from generated ID array.
  108. *
  109. * @type {(arr: Array<number>, prefix: string) => string}
  110. */
  111. const getIDstring = (arr, prefix) => {
  112. return prefix + arr.map((i) => generateIDchars[i]).join('');
  113. };
  114. /**
  115. * Remove unused and minify used IDs
  116. * (only if there are no any <style> or <script>).
  117. *
  118. * @author Kir Belevich
  119. *
  120. * @type {import('../lib/types').Plugin<{
  121. * remove?: boolean,
  122. * minify?: boolean,
  123. * prefix?: string,
  124. * preserve?: Array<string>,
  125. * preservePrefixes?: Array<string>,
  126. * force?: boolean,
  127. * }>}
  128. */
  129. exports.fn = (_root, params) => {
  130. const {
  131. remove = true,
  132. minify = true,
  133. prefix = '',
  134. preserve = [],
  135. preservePrefixes = [],
  136. force = false,
  137. } = params;
  138. const preserveIDs = new Set(
  139. Array.isArray(preserve) ? preserve : preserve ? [preserve] : []
  140. );
  141. const preserveIDPrefixes = Array.isArray(preservePrefixes)
  142. ? preservePrefixes
  143. : preservePrefixes
  144. ? [preservePrefixes]
  145. : [];
  146. /**
  147. * @type {Map<string, XastElement>}
  148. */
  149. const nodeById = new Map();
  150. /**
  151. * @type {Map<string, Array<{element: XastElement, name: string, value: string }>>}
  152. */
  153. const referencesById = new Map();
  154. let deoptimized = false;
  155. return {
  156. element: {
  157. enter: (node) => {
  158. if (force == false) {
  159. // deoptimize if style or script elements are present
  160. if (
  161. (node.name === 'style' || node.name === 'script') &&
  162. node.children.length !== 0
  163. ) {
  164. deoptimized = true;
  165. return;
  166. }
  167. // avoid removing IDs if the whole SVG consists only of defs
  168. if (node.name === 'svg') {
  169. let hasDefsOnly = true;
  170. for (const child of node.children) {
  171. if (child.type !== 'element' || child.name !== 'defs') {
  172. hasDefsOnly = false;
  173. break;
  174. }
  175. }
  176. if (hasDefsOnly) {
  177. return visitSkip;
  178. }
  179. }
  180. }
  181. for (const [name, value] of Object.entries(node.attributes)) {
  182. if (name === 'id') {
  183. // collect all ids
  184. const id = value;
  185. if (nodeById.has(id)) {
  186. delete node.attributes.id; // remove repeated id
  187. } else {
  188. nodeById.set(id, node);
  189. }
  190. } else {
  191. // collect all references
  192. /**
  193. * @type {null | string}
  194. */
  195. let id = null;
  196. if (referencesProps.includes(name)) {
  197. const match = value.match(regReferencesUrl);
  198. if (match != null) {
  199. id = match[2]; // url() reference
  200. }
  201. }
  202. if (name === 'href' || name.endsWith(':href')) {
  203. const match = value.match(regReferencesHref);
  204. if (match != null) {
  205. id = match[1]; // href reference
  206. }
  207. }
  208. if (name === 'begin') {
  209. const match = value.match(regReferencesBegin);
  210. if (match != null) {
  211. id = match[1]; // href reference
  212. }
  213. }
  214. if (id != null) {
  215. let refs = referencesById.get(id);
  216. if (refs == null) {
  217. refs = [];
  218. referencesById.set(id, refs);
  219. }
  220. refs.push({ element: node, name, value });
  221. }
  222. }
  223. }
  224. },
  225. },
  226. root: {
  227. exit: () => {
  228. if (deoptimized) {
  229. return;
  230. }
  231. /**
  232. * @type {(id: string) => boolean}
  233. **/
  234. const isIdPreserved = (id) =>
  235. preserveIDs.has(id) || hasStringPrefix(id, preserveIDPrefixes);
  236. /**
  237. * @type {null | Array<number>}
  238. */
  239. let currentID = null;
  240. for (const [id, refs] of referencesById) {
  241. const node = nodeById.get(id);
  242. if (node != null) {
  243. // replace referenced IDs with the minified ones
  244. if (minify && isIdPreserved(id) === false) {
  245. /**
  246. * @type {null | string}
  247. */
  248. let currentIDString = null;
  249. do {
  250. currentID = generateID(currentID);
  251. currentIDString = getIDstring(currentID, prefix);
  252. } while (isIdPreserved(currentIDString));
  253. node.attributes.id = currentIDString;
  254. for (const { element, name, value } of refs) {
  255. if (value.includes('#')) {
  256. // replace id in href and url()
  257. element.attributes[name] = value.replace(
  258. `#${id}`,
  259. `#${currentIDString}`
  260. );
  261. } else {
  262. // replace id in begin attribute
  263. element.attributes[name] = value.replace(
  264. `${id}.`,
  265. `${currentIDString}.`
  266. );
  267. }
  268. }
  269. }
  270. // keep referenced node
  271. nodeById.delete(id);
  272. }
  273. }
  274. // remove non-referenced IDs attributes from elements
  275. if (remove) {
  276. for (const [id, node] of nodeById) {
  277. if (isIdPreserved(id) === false) {
  278. delete node.attributes.id;
  279. }
  280. }
  281. }
  282. },
  283. },
  284. };
  285. };