'use strict'; const { elems } = require('./_collections'); /** * @typedef {import('../lib/types').XastElement} XastElement */ exports.name = 'removeXlink'; exports.description = 'remove xlink namespace and replaces attributes with the SVG 2 equivalent where applicable'; /** URI indicating the Xlink namespace. */ const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink'; /** * Map of `xlink:show` values to the SVG 2 `target` attribute values. * * @type {Record} * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:show#usage_notes */ const SHOW_TO_TARGET = { new: '_blank', replace: '_self', }; /** * Elements that use xlink:href, but were deprecated in SVG 2 and therefore * don't support the SVG 2 href attribute. * * @type {Set} * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:href * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/href */ const LEGACY_ELEMENTS = new Set([ 'cursor', 'filter', 'font-face-uri', 'glyphRef', 'tref', ]); /** * @param {XastElement} node * @param {string[]} prefixes * @param {string} attr * @returns {string[]} */ const findPrefixedAttrs = (node, prefixes, attr) => { return prefixes .map((prefix) => `${prefix}:${attr}`) .filter((attr) => node.attributes[attr] != null); }; /** * Removes XLink namespace prefixes and converts references to XLink attributes * to the native SVG equivalent. * * The XLink namespace is deprecated in SVG 2. * * @type {import('./plugins-types').Plugin<'removeXlink'>} * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:href */ exports.fn = (_, params) => { const { includeLegacy } = params; /** * XLink namespace prefixes that are currently in the stack. * * @type {string[]} */ const xlinkPrefixes = []; /** * Namespace prefixes that exist in {@link xlinkPrefixes} but were overridden * in a child element to point to another namespace, and so is not treated as * an XLink attribute. * * @type {string[]} */ const overriddenPrefixes = []; /** * Namespace prefixes that were used in one of the {@link LEGACY_ELEMENTS}. * * @type {string[]} */ const usedInLegacyElement = []; return { element: { enter: (node) => { for (const [key, value] of Object.entries(node.attributes)) { if (key.startsWith('xmlns:')) { const prefix = key.split(':', 2)[1]; if (value === XLINK_NAMESPACE) { xlinkPrefixes.push(prefix); continue; } if (xlinkPrefixes.includes(prefix)) { overriddenPrefixes.push(prefix); } } } if ( overriddenPrefixes.some((prefix) => xlinkPrefixes.includes(prefix)) ) { return; } const showAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'show'); let showHandled = node.attributes.target != null; for (let i = showAttrs.length - 1; i >= 0; i--) { const attr = showAttrs[i]; const value = node.attributes[attr]; const mapping = SHOW_TO_TARGET[value]; if (showHandled || mapping == null) { delete node.attributes[attr]; continue; } if (mapping !== elems[node.name]?.defaults?.target) { node.attributes.target = mapping; } delete node.attributes[attr]; showHandled = true; } const titleAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'title'); for (let i = titleAttrs.length - 1; i >= 0; i--) { const attr = titleAttrs[i]; const value = node.attributes[attr]; const hasTitle = node.children.filter( (child) => child.type === 'element' && child.name === 'title', ); if (hasTitle.length > 0) { delete node.attributes[attr]; continue; } /** @type {XastElement} */ const titleTag = { type: 'element', name: 'title', attributes: {}, children: [ { type: 'text', value, }, ], }; Object.defineProperty(titleTag, 'parentNode', { writable: true, value: node, }); node.children.unshift(titleTag); delete node.attributes[attr]; } const hrefAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'href'); if ( hrefAttrs.length > 0 && LEGACY_ELEMENTS.has(node.name) && !includeLegacy ) { hrefAttrs .map((attr) => attr.split(':', 1)[0]) .forEach((prefix) => usedInLegacyElement.push(prefix)); return; } for (let i = hrefAttrs.length - 1; i >= 0; i--) { const attr = hrefAttrs[i]; const value = node.attributes[attr]; if (node.attributes.href != null) { delete node.attributes[attr]; continue; } node.attributes.href = value; delete node.attributes[attr]; } }, exit: (node) => { for (const [key, value] of Object.entries(node.attributes)) { const [prefix, attr] = key.split(':', 2); if ( xlinkPrefixes.includes(prefix) && !overriddenPrefixes.includes(prefix) && !usedInLegacyElement.includes(prefix) && !includeLegacy ) { delete node.attributes[key]; continue; } if (key.startsWith('xmlns:') && !usedInLegacyElement.includes(attr)) { if (value === XLINK_NAMESPACE) { const index = xlinkPrefixes.indexOf(attr); xlinkPrefixes.splice(index, 1); delete node.attributes[key]; continue; } if (overriddenPrefixes.includes(prefix)) { const index = overriddenPrefixes.indexOf(attr); overriddenPrefixes.splice(index, 1); } } } }, }, }; };