diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 438b3d4ba..e0decd108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ Our documentation is maintained in [MDX](https://mdxjs.com/), which is Markdown To preview local changes, follow the steps to run the website locally in [svg/svgo.dev](https://github.com/svg/svgo.dev). -New plugins, plugin parameters, and notable any features should be paired with documentation in the [`docs/`](./docs/) directory and pushed in the same pull request. +New plugins, plugin parameters, and notable features should be paired with documentation in the [`docs/`](./docs/) directory and included in the same pull request. ## Funding diff --git a/docs/03-plugins/convert-colors.mdx b/docs/03-plugins/convert-colors.mdx index 073fff753..ceb07c590 100644 --- a/docs/03-plugins/convert-colors.mdx +++ b/docs/03-plugins/convert-colors.mdx @@ -8,22 +8,22 @@ svgo: description: If to convert all instances of a color to currentColor. This means to inherit the active foreground color, for example in HTML5 this would be the color property in CSS. default: false names2hex: - description: If to convert color names to the hex equivilent. + description: If to convert color names to the hex equivalent. default: true rgb2hex: - description: If to convert RGB colors to the hex equivilent, does ignores RGBA. + description: If to convert RGB colors to the hex equivalent, does ignores RGBA. default: true shorthex: - description: If to convert 6 character hex colors to the 3 character equivilents where possible. + description: If to convert 6 character hex colors to the 3 character equivalent where possible. default: true shortname: - description: If to convert hex colors to the color name, if the color name is shorter then the hex equivilent. + description: If to convert hex colors to the color name, if the color name is shorter then the hex equivalent. default: true --- -Converts color references to the shortest equivilent. +Converts color references to the shortest equivalent. -Colors can be represented in various notations, the following table showcases some equivilent colors: +Colors can be represented in various notations, the following table showcases some equivalent colors: | Name | rgb() | #rrggbb | #rgb | |---|---|---|---| diff --git a/docs/03-plugins/remove-xlink.mdx b/docs/03-plugins/remove-xlink.mdx new file mode 100644 index 000000000..0190549d2 --- /dev/null +++ b/docs/03-plugins/remove-xlink.mdx @@ -0,0 +1,47 @@ +--- +title: Remove XLink +svgo: + pluginId: removeXlink + parameters: + includeLegacy: + description: If to update references to XLink in elements that don't support the SVG 2 href attribute, like <filter> and <tref>. + default: false +--- + +Removes XLink namespace prefixes and converts references to XLink attributes to the native SVG equivalent by performing the following operations: + +* Convert `*:href` to [`href`](https://developer.mozilla.org/docs/Web/SVG/Attribute/href). +* Convert `*:show` to [`target`](https://developer.mozilla.org/docs/Web/SVG/Attribute/target). +* Convert `*:title` to [``](https://developer.mozilla.org/docs/Web/SVG/Element/title). +* Drop all other references to the XLink namespace. +* Remove XLink namespace declarations. + +In most cases this will remove all references to XLink, but if legacy elements that are deprecated or removed in SVG 2 are found, the references are preserved as those elements do not support the SVG 2 `href` attribute. You can set `includeLegacy` to `true` to apply the plugin in this case too. + +The following support `xlink:href` but not the SVG 2 `href` attribute: + +* [`<cursor>`](https://developer.mozilla.org/docs/Web/SVG/Element/cursor) +* [`<filter>`](https://developer.mozilla.org/docs/Web/SVG/Element/filter) +* [`<font-face-uri>`](https://developer.mozilla.org/docs/Web/SVG/Element/font-face-uri) +* [`<glyphRef>`](https://developer.mozilla.org/docs/Web/SVG/Element/glyphRef) +* [`<tref>`](https://developer.mozilla.org/docs/Web/SVG/Element/tref) + +It's recommended to use this plugin if you intend to inline SVGs into an HTML document, the `includeLegacy` can be safely used in this case too. HTML does not support explicit namespaces, so namespace prefixes are ignored by the browser anyway. + +:::danger + +This replaces XLink with features that are only supported in the SVGO 2 spec, and so breaks compatibility with the SVG 1.1. + +::: + +## Usage + +<PluginUsage/> + +## Demo + +<PluginDemo/> + +## Implementation + +* https://github.com/svg/svgo/blob/main/plugins/removeXlink.js diff --git a/docs/03-plugins/remove-xmlns.mdx b/docs/03-plugins/remove-xmlns.mdx index c648bea6f..f97c6d355 100644 --- a/docs/03-plugins/remove-xmlns.mdx +++ b/docs/03-plugins/remove-xmlns.mdx @@ -6,7 +6,13 @@ svgo: Removes the `xmlns` attribute from the top-most `<svg>` element in the document. -This optimization is encouraged if you plan to use your SVGs inline an HTML document. The HTML syntax does not support explicit namespaces, so these are ignored by the browser anyway. +It's recommended to use this plugin if you intend to inline SVGs into an HTML document. HTML does not support explicit namespaces, so these are ignored by the browser anyway. + +:::tip + +This plugin pairs well with the [Remove XLink](/docs/plugins/remove-xlink/) plugin. Remove XLink drops XLink namespaces and migrates references to them to the modern equivalent, supported by SVG 2 and inline an HTML document. When using this, it's recommended to enable Remove XLink too. + +::: :::caution diff --git a/lib/builtin.js b/lib/builtin.js index c962f4358..28a380ec9 100644 --- a/lib/builtin.js +++ b/lib/builtin.js @@ -48,6 +48,7 @@ exports.builtin = [ require('../plugins/removeUselessDefs.js'), require('../plugins/removeUselessStrokeAndFill.js'), require('../plugins/removeViewBox.js'), + require('../plugins/removeXlink.js'), require('../plugins/removeXMLNS.js'), require('../plugins/removeXMLProcInst.js'), require('../plugins/reusePaths.js'), diff --git a/package.json b/package.json index 6415c95ac..bc9eee83e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "packageManager": "yarn@2.4.3", "name": "svgo", - "version": "3.0.3", + "version": "3.0.4", "description": "Nodejs-based tool for optimizing SVG vector graphics files", "license": "MIT", "keywords": [ diff --git a/plugins/plugins-types.ts b/plugins/plugins-types.ts index f872a8d58..8e3972c1c 100644 --- a/plugins/plugins-types.ts +++ b/plugins/plugins-types.ts @@ -241,6 +241,16 @@ export type BuiltinsWithOptionalParams = DefaultPlugins & { removeRasterImages: void; removeScriptElement: void; removeStyleElement: void; + removeXlink: { + /** + * By default this plugin ignores legacy elements that were deprecated or + * removed in SVG 2. Set to true to force performing operations on those + * too. + * + * @default false + */ + includeLegacy: boolean + }; removeXMLNS: void; reusePaths: void; }; diff --git a/plugins/removeEditorsNSData.js b/plugins/removeEditorsNSData.js index cf8016cde..e85bef898 100644 --- a/plugins/removeEditorsNSData.js +++ b/plugins/removeEditorsNSData.js @@ -30,7 +30,7 @@ exports.fn = (_root, params) => { return { element: { enter: (node, parentNode) => { - // collect namespace aliases from svg element + // collect namespace prefixes from svg element if (node.name === 'svg') { for (const [name, value] of Object.entries(node.attributes)) { if (name.startsWith('xmlns:') && namespaces.includes(value)) { diff --git a/plugins/removeXMLNS.js b/plugins/removeXMLNS.js index 6312629df..b7ec69a12 100644 --- a/plugins/removeXMLNS.js +++ b/plugins/removeXMLNS.js @@ -22,7 +22,6 @@ exports.fn = () => { enter: (node) => { if (node.name === 'svg') { delete node.attributes.xmlns; - delete node.attributes['xmlns:xlink']; } }, }, diff --git a/plugins/removeXlink.js b/plugins/removeXlink.js new file mode 100644 index 000000000..db55ab50b --- /dev/null +++ b/plugins/removeXlink.js @@ -0,0 +1,226 @@ +'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<string, string>} + * @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 {string[]} + * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:href + * @see https://developer.mozilla.org/docs/Web/SVG/Attribute/href + */ +const LEGACY_ELEMENTS = [ + '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 overriden + * 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.includes(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); + } + } + } + }, + }, + }; +}; diff --git a/test/plugins/removeXlink.01.svg b/test/plugins/removeXlink.01.svg new file mode 100644 index 000000000..94fa7b499 --- /dev/null +++ b/test/plugins/removeXlink.01.svg @@ -0,0 +1,25 @@ +Remove xmlns:xlink and replace xlink:href with href attribute + +=== + +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 348.61 100"> + <defs> + <linearGradient id="a" x1="263.36" y1="14.74" x2="333.47" y2="84.85" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#45afe4"/> + <stop offset="1" stop-color="#364f9e"/> + </linearGradient> + <linearGradient id="b" x1="262.64" y1="15.46" x2="332.75" y2="85.57" xlink:href="#a"/> + </defs> +</svg> + +@@@ + +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348.61 100"> + <defs> + <linearGradient id="a" x1="263.36" y1="14.74" x2="333.47" y2="84.85" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#45afe4"/> + <stop offset="1" stop-color="#364f9e"/> + </linearGradient> + <linearGradient id="b" x1="262.64" y1="15.46" x2="332.75" y2="85.57" href="#a"/> + </defs> +</svg> diff --git a/test/plugins/removeXlink.02.svg b/test/plugins/removeXlink.02.svg new file mode 100644 index 000000000..07649980e --- /dev/null +++ b/test/plugins/removeXlink.02.svg @@ -0,0 +1,25 @@ +Remove xlink namespace even if it's under another prefix. + +=== + +<svg xmlns="http://www.w3.org/2000/svg" xmlns:uwu="http://www.w3.org/1999/xlink" viewBox="0 0 348.61 100"> + <defs> + <linearGradient id="a" x1="263.36" y1="14.74" x2="333.47" y2="84.85" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#45afe4"/> + <stop offset="1" stop-color="#364f9e"/> + </linearGradient> + <linearGradient id="b" x1="262.64" y1="15.46" x2="332.75" y2="85.57" uwu:href="#a"/> + </defs> +</svg> + +@@@ + +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348.61 100"> + <defs> + <linearGradient id="a" x1="263.36" y1="14.74" x2="333.47" y2="84.85" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#45afe4"/> + <stop offset="1" stop-color="#364f9e"/> + </linearGradient> + <linearGradient id="b" x1="262.64" y1="15.46" x2="332.75" y2="85.57" href="#a"/> + </defs> +</svg> diff --git a/test/plugins/removeXlink.03.svg b/test/plugins/removeXlink.03.svg new file mode 100644 index 000000000..4b30f11c5 --- /dev/null +++ b/test/plugins/removeXlink.03.svg @@ -0,0 +1,19 @@ +Convert xlink:href and xlink:show to href and target, and convert xlink:title +to title node. + +=== + +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 50 50"> + <a xlink:href="https://duckduckgo.com" xlink:show="new" xlink:title="DuckDuckGo Homepage"> + <text x="0" y="10">uwu</text> + </a> +</svg> + +@@@ + +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"> + <a target="_blank" href="https://duckduckgo.com"> + <title>DuckDuckGo Homepage + uwu + + diff --git a/test/plugins/removeXlink.04.svg b/test/plugins/removeXlink.04.svg new file mode 100644 index 000000000..7bb838527 --- /dev/null +++ b/test/plugins/removeXlink.04.svg @@ -0,0 +1,25 @@ +Drops other xlink attributes. + +=== + + + + + + + + + + + +@@@ + + + + + + + + + +