From bf0b5fa8351c801ed9055bcc7755e8a0e29c00f2 Mon Sep 17 00:00:00 2001 From: jwaes Date: Sun, 23 Feb 2025 09:01:57 +0000 Subject: [PATCH 1/4] initial release for web_iconify and web_iconify_proxy --- .pre-commit-config.yaml | 10 +- web_iconify/README.rst | 101 + web_iconify/__init__.py | 0 web_iconify/__manifest__.py | 23 + web_iconify/pyproject.toml | 3 + web_iconify/readme/CONTRIBUTORS.md | 1 + web_iconify/readme/DESCRIPTION.md | 3 + web_iconify/readme/HISTORY.md | 3 + web_iconify/readme/USAGE.md | 11 + web_iconify/static/description/index.html | 450 ++++ .../static/lib/iconify/iconify-icon.js | 2301 +++++++++++++++++ .../static/lib/iconify/iconify-icon.min.js | 12 + web_iconify/static/src/css/web_iconify.css | 5 + web_iconify_proxy/README.rst | 94 + web_iconify_proxy/__init__.py | 1 + web_iconify_proxy/__manifest__.py | 23 + web_iconify_proxy/controllers/__init__.py | 1 + web_iconify_proxy/controllers/main.py | 213 ++ web_iconify_proxy/pyproject.toml | 3 + web_iconify_proxy/readme/CONTRIBUTORS.md | 1 + web_iconify_proxy/readme/DESCRIPTION.md | 4 + web_iconify_proxy/readme/HISTORY.md | 3 + web_iconify_proxy/readme/USAGE.md | 3 + .../static/description/index.html | 446 ++++ .../static/src/js/iconify_api_provider.js | 8 + web_iconify_proxy/tests/__init__.py | 1 + web_iconify_proxy/tests/test_main.py | 97 + 27 files changed, 3816 insertions(+), 5 deletions(-) create mode 100644 web_iconify/README.rst create mode 100644 web_iconify/__init__.py create mode 100644 web_iconify/__manifest__.py create mode 100644 web_iconify/pyproject.toml create mode 100644 web_iconify/readme/CONTRIBUTORS.md create mode 100644 web_iconify/readme/DESCRIPTION.md create mode 100644 web_iconify/readme/HISTORY.md create mode 100644 web_iconify/readme/USAGE.md create mode 100644 web_iconify/static/description/index.html create mode 100644 web_iconify/static/lib/iconify/iconify-icon.js create mode 100644 web_iconify/static/lib/iconify/iconify-icon.min.js create mode 100644 web_iconify/static/src/css/web_iconify.css create mode 100644 web_iconify_proxy/README.rst create mode 100644 web_iconify_proxy/__init__.py create mode 100644 web_iconify_proxy/__manifest__.py create mode 100644 web_iconify_proxy/controllers/__init__.py create mode 100644 web_iconify_proxy/controllers/main.py create mode 100644 web_iconify_proxy/pyproject.toml create mode 100644 web_iconify_proxy/readme/CONTRIBUTORS.md create mode 100644 web_iconify_proxy/readme/DESCRIPTION.md create mode 100644 web_iconify_proxy/readme/HISTORY.md create mode 100644 web_iconify_proxy/readme/USAGE.md create mode 100644 web_iconify_proxy/static/description/index.html create mode 100644 web_iconify_proxy/static/src/js/iconify_api_provider.js create mode 100644 web_iconify_proxy/tests/__init__.py create mode 100644 web_iconify_proxy/tests/test_main.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a0520aa930..16c51300ee1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: hooks: - id: whool-init - repo: https://github.com/oca/maintainer-tools - rev: bf9ecb9938b6a5deca0ff3d870fbd3f33341fded + rev: 1a451eff05ce754b25428acc5d542e44050545b0 hooks: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons @@ -60,7 +60,7 @@ repos: - --convert-fragments-to-markdown - id: oca-gen-external-dependencies - repo: https://github.com/OCA/odoo-pre-commit-hooks - rev: v0.0.33 + rev: v0.1.1 hooks: - id: oca-checks-odoo-module - id: oca-checks-po @@ -96,7 +96,7 @@ repos: - "eslint@9.12.0" - "eslint-plugin-jsdoc@50.3.1" - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace # exclude autogenerated files @@ -118,13 +118,13 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.9.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/OCA/pylint-odoo - rev: v9.1.3 + rev: v9.3.2 hooks: - id: pylint_odoo name: pylint with optional checks diff --git a/web_iconify/README.rst b/web_iconify/README.rst new file mode 100644 index 00000000000..b8f1ecff147 --- /dev/null +++ b/web_iconify/README.rst @@ -0,0 +1,101 @@ +=========== +Web Iconify +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6eadd39cc54620c27d3acb20fb2d6a05543446e8a7e66fa53253a519afd19096 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_iconify + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_iconify + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify's on-demand loading feature. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use icons in your Odoo views, use the following syntax: + +.. code:: html + + + +Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website: + +https://iconify.design/ + +Changelog +========= + +18.0.1.0.0 (2025-02-23) +----------------------- + +- Initial version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* jaco.tech + +Contributors +------------ + +- Jaco Waes + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_iconify/__init__.py b/web_iconify/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web_iconify/__manifest__.py b/web_iconify/__manifest__.py new file mode 100644 index 00000000000..ec444d50338 --- /dev/null +++ b/web_iconify/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 jaco.tech +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web Iconify", + "version": "18.0.1.0.0", + "category": "Web", + "summary": "Provides core Iconify integration for Odoo. This module " + "integrates the Iconify SVG framework into Odoo, allowing developers " + "to easily use a wide variety of icons in their modules.", + "author": "jaco.tech, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "depends": ["web"], + "license": "AGPL-3", + "assets": { + "web.assets_backend": [ + "/web_iconify/static/lib/iconify/iconify-icon.js", + "/web_iconify/static/src/css/web_iconify.css", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/web_iconify/pyproject.toml b/web_iconify/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_iconify/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_iconify/readme/CONTRIBUTORS.md b/web_iconify/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..2541b203029 --- /dev/null +++ b/web_iconify/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jaco Waes \ diff --git a/web_iconify/readme/DESCRIPTION.md b/web_iconify/readme/DESCRIPTION.md new file mode 100644 index 00000000000..f4762b54459 --- /dev/null +++ b/web_iconify/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify's on-demand loading feature. diff --git a/web_iconify/readme/HISTORY.md b/web_iconify/readme/HISTORY.md new file mode 100644 index 00000000000..90f6389743d --- /dev/null +++ b/web_iconify/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.1.0.0 (2025-02-23) + +- Initial version. diff --git a/web_iconify/readme/USAGE.md b/web_iconify/readme/USAGE.md new file mode 100644 index 00000000000..0754c4efaab --- /dev/null +++ b/web_iconify/readme/USAGE.md @@ -0,0 +1,11 @@ +To use icons in your Odoo views, use the following syntax: + +``` html + +``` + +Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website: + + diff --git a/web_iconify/static/description/index.html b/web_iconify/static/description/index.html new file mode 100644 index 00000000000..0ae2973282f --- /dev/null +++ b/web_iconify/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +Web Iconify + + + +
+

Web Iconify

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify’s on-demand loading feature.

+

Table of contents

+ +
+

Usage

+

To use icons in your Odoo views, use the following syntax:

+
+<iconify-icon icon="prefix:name"></iconify-icon>
+
+

Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website:

+

https://iconify.design/

+
+
+

Changelog

+
+

18.0.1.0.0 (2025-02-23)

+
    +
  • Initial version.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • jaco.tech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_iconify/static/lib/iconify/iconify-icon.js b/web_iconify/static/lib/iconify/iconify-icon.js new file mode 100644 index 00000000000..b555e57151a --- /dev/null +++ b/web_iconify/static/lib/iconify/iconify-icon.js @@ -0,0 +1,2301 @@ +/** +* (c) Iconify +* +* For the full copyright and license information, please view the license.txt +* files at https://github.com/iconify/iconify +* +* Licensed under MIT. +* +* @license MIT +* @version 2.3.0 +*/ +(function () { + 'use strict'; + + const defaultIconDimensions = Object.freeze( + { + left: 0, + top: 0, + width: 16, + height: 16 + } + ); + const defaultIconTransformations = Object.freeze({ + rotate: 0, + vFlip: false, + hFlip: false + }); + const defaultIconProps = Object.freeze({ + ...defaultIconDimensions, + ...defaultIconTransformations + }); + const defaultExtendedIconProps = Object.freeze({ + ...defaultIconProps, + body: "", + hidden: false + }); + + const defaultIconSizeCustomisations = Object.freeze({ + width: null, + height: null + }); + const defaultIconCustomisations = Object.freeze({ + // Dimensions + ...defaultIconSizeCustomisations, + // Transformations + ...defaultIconTransformations + }); + + function rotateFromString(value, defaultValue = 0) { + const units = value.replace(/^-?[0-9.]*/, ""); + function cleanup(value2) { + while (value2 < 0) { + value2 += 4; + } + return value2 % 4; + } + if (units === "") { + const num = parseInt(value); + return isNaN(num) ? 0 : cleanup(num); + } else if (units !== value) { + let split = 0; + switch (units) { + case "%": + split = 25; + break; + case "deg": + split = 90; + } + if (split) { + let num = parseFloat(value.slice(0, value.length - units.length)); + if (isNaN(num)) { + return 0; + } + num = num / split; + return num % 1 === 0 ? cleanup(num) : 0; + } + } + return defaultValue; + } + + const separator = /[\s,]+/; + function flipFromString(custom, flip) { + flip.split(separator).forEach((str) => { + const value = str.trim(); + switch (value) { + case "horizontal": + custom.hFlip = true; + break; + case "vertical": + custom.vFlip = true; + break; + } + }); + } + + const defaultCustomisations = { + ...defaultIconCustomisations, + preserveAspectRatio: '', + }; + /** + * Get customisations + */ + function getCustomisations(node) { + const customisations = { + ...defaultCustomisations, + }; + const attr = (key, def) => node.getAttribute(key) || def; + // Dimensions + customisations.width = attr('width', null); + customisations.height = attr('height', null); + // Rotation + customisations.rotate = rotateFromString(attr('rotate', '')); + // Flip + flipFromString(customisations, attr('flip', '')); + // SVG attributes + customisations.preserveAspectRatio = attr('preserveAspectRatio', attr('preserveaspectratio', '')); + return customisations; + } + /** + * Check if customisations have been updated + */ + function haveCustomisationsChanged(value1, value2) { + for (const key in defaultCustomisations) { + if (value1[key] !== value2[key]) { + return true; + } + } + return false; + } + + const matchIconName = /^[a-z0-9]+(-[a-z0-9]+)*$/; + const stringToIcon = (value, validate, allowSimpleName, provider = "") => { + const colonSeparated = value.split(":"); + if (value.slice(0, 1) === "@") { + if (colonSeparated.length < 2 || colonSeparated.length > 3) { + return null; + } + provider = colonSeparated.shift().slice(1); + } + if (colonSeparated.length > 3 || !colonSeparated.length) { + return null; + } + if (colonSeparated.length > 1) { + const name2 = colonSeparated.pop(); + const prefix = colonSeparated.pop(); + const result = { + // Allow provider without '@': "provider:prefix:name" + provider: colonSeparated.length > 0 ? colonSeparated[0] : provider, + prefix, + name: name2 + }; + return validate && !validateIconName(result) ? null : result; + } + const name = colonSeparated[0]; + const dashSeparated = name.split("-"); + if (dashSeparated.length > 1) { + const result = { + provider, + prefix: dashSeparated.shift(), + name: dashSeparated.join("-") + }; + return validate && !validateIconName(result) ? null : result; + } + if (allowSimpleName && provider === "") { + const result = { + provider, + prefix: "", + name + }; + return validate && !validateIconName(result, allowSimpleName) ? null : result; + } + return null; + }; + const validateIconName = (icon, allowSimpleName) => { + if (!icon) { + return false; + } + return !!// Check prefix: cannot be empty, unless allowSimpleName is enabled + // Check name: cannot be empty + ((allowSimpleName && icon.prefix === "" || !!icon.prefix) && !!icon.name); + }; + + function mergeIconTransformations(obj1, obj2) { + const result = {}; + if (!obj1.hFlip !== !obj2.hFlip) { + result.hFlip = true; + } + if (!obj1.vFlip !== !obj2.vFlip) { + result.vFlip = true; + } + const rotate = ((obj1.rotate || 0) + (obj2.rotate || 0)) % 4; + if (rotate) { + result.rotate = rotate; + } + return result; + } + + function mergeIconData(parent, child) { + const result = mergeIconTransformations(parent, child); + for (const key in defaultExtendedIconProps) { + if (key in defaultIconTransformations) { + if (key in parent && !(key in result)) { + result[key] = defaultIconTransformations[key]; + } + } else if (key in child) { + result[key] = child[key]; + } else if (key in parent) { + result[key] = parent[key]; + } + } + return result; + } + + function getIconsTree(data, names) { + const icons = data.icons; + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + const resolved = /* @__PURE__ */ Object.create(null); + function resolve(name) { + if (icons[name]) { + return resolved[name] = []; + } + if (!(name in resolved)) { + resolved[name] = null; + const parent = aliases[name] && aliases[name].parent; + const value = parent && resolve(parent); + if (value) { + resolved[name] = [parent].concat(value); + } + } + return resolved[name]; + } + (Object.keys(icons).concat(Object.keys(aliases))).forEach(resolve); + return resolved; + } + + function internalGetIconData(data, name, tree) { + const icons = data.icons; + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + let currentProps = {}; + function parse(name2) { + currentProps = mergeIconData( + icons[name2] || aliases[name2], + currentProps + ); + } + parse(name); + tree.forEach(parse); + return mergeIconData(data, currentProps); + } + + function parseIconSet(data, callback) { + const names = []; + if (typeof data !== "object" || typeof data.icons !== "object") { + return names; + } + if (data.not_found instanceof Array) { + data.not_found.forEach((name) => { + callback(name, null); + names.push(name); + }); + } + const tree = getIconsTree(data); + for (const name in tree) { + const item = tree[name]; + if (item) { + callback(name, internalGetIconData(data, name, item)); + names.push(name); + } + } + return names; + } + + const optionalPropertyDefaults = { + provider: "", + aliases: {}, + not_found: {}, + ...defaultIconDimensions + }; + function checkOptionalProps(item, defaults) { + for (const prop in defaults) { + if (prop in item && typeof item[prop] !== typeof defaults[prop]) { + return false; + } + } + return true; + } + function quicklyValidateIconSet(obj) { + if (typeof obj !== "object" || obj === null) { + return null; + } + const data = obj; + if (typeof data.prefix !== "string" || !obj.icons || typeof obj.icons !== "object") { + return null; + } + if (!checkOptionalProps(obj, optionalPropertyDefaults)) { + return null; + } + const icons = data.icons; + for (const name in icons) { + const icon = icons[name]; + if ( + // Name cannot be empty + !name || // Must have body + typeof icon.body !== "string" || // Check other props + !checkOptionalProps( + icon, + defaultExtendedIconProps + ) + ) { + return null; + } + } + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + for (const name in aliases) { + const icon = aliases[name]; + const parent = icon.parent; + if ( + // Name cannot be empty + !name || // Parent must be set and point to existing icon + typeof parent !== "string" || !icons[parent] && !aliases[parent] || // Check other props + !checkOptionalProps( + icon, + defaultExtendedIconProps + ) + ) { + return null; + } + } + return data; + } + + const dataStorage = /* @__PURE__ */ Object.create(null); + function newStorage(provider, prefix) { + return { + provider, + prefix, + icons: /* @__PURE__ */ Object.create(null), + missing: /* @__PURE__ */ new Set() + }; + } + function getStorage(provider, prefix) { + const providerStorage = dataStorage[provider] || (dataStorage[provider] = /* @__PURE__ */ Object.create(null)); + return providerStorage[prefix] || (providerStorage[prefix] = newStorage(provider, prefix)); + } + function addIconSet(storage, data) { + if (!quicklyValidateIconSet(data)) { + return []; + } + return parseIconSet(data, (name, icon) => { + if (icon) { + storage.icons[name] = icon; + } else { + storage.missing.add(name); + } + }); + } + function addIconToStorage(storage, name, icon) { + try { + if (typeof icon.body === "string") { + storage.icons[name] = { ...icon }; + return true; + } + } catch (err) { + } + return false; + } + function listIcons(provider, prefix) { + let allIcons = []; + const providers = typeof provider === "string" ? [provider] : Object.keys(dataStorage); + providers.forEach((provider2) => { + const prefixes = typeof provider2 === "string" && typeof prefix === "string" ? [prefix] : Object.keys(dataStorage[provider2] || {}); + prefixes.forEach((prefix2) => { + const storage = getStorage(provider2, prefix2); + allIcons = allIcons.concat( + Object.keys(storage.icons).map( + (name) => (provider2 !== "" ? "@" + provider2 + ":" : "") + prefix2 + ":" + name + ) + ); + }); + }); + return allIcons; + } + + let simpleNames = false; + function allowSimpleNames(allow) { + if (typeof allow === "boolean") { + simpleNames = allow; + } + return simpleNames; + } + function getIconData(name) { + const icon = typeof name === "string" ? stringToIcon(name, true, simpleNames) : name; + if (icon) { + const storage = getStorage(icon.provider, icon.prefix); + const iconName = icon.name; + return storage.icons[iconName] || (storage.missing.has(iconName) ? null : void 0); + } + } + function addIcon(name, data) { + const icon = stringToIcon(name, true, simpleNames); + if (!icon) { + return false; + } + const storage = getStorage(icon.provider, icon.prefix); + if (data) { + return addIconToStorage(storage, icon.name, data); + } else { + storage.missing.add(icon.name); + return true; + } + } + function addCollection(data, provider) { + if (typeof data !== "object") { + return false; + } + if (typeof provider !== "string") { + provider = data.provider || ""; + } + if (simpleNames && !provider && !data.prefix) { + let added = false; + if (quicklyValidateIconSet(data)) { + data.prefix = ""; + parseIconSet(data, (name, icon) => { + if (addIcon(name, icon)) { + added = true; + } + }); + } + return added; + } + const prefix = data.prefix; + if (!validateIconName({ + provider, + prefix, + name: "a" + })) { + return false; + } + const storage = getStorage(provider, prefix); + return !!addIconSet(storage, data); + } + function iconLoaded(name) { + return !!getIconData(name); + } + function getIcon(name) { + const result = getIconData(name); + return result ? { + ...defaultIconProps, + ...result + } : result; + } + + function sortIcons(icons) { + const result = { + loaded: [], + missing: [], + pending: [] + }; + const storage = /* @__PURE__ */ Object.create(null); + icons.sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + if (a.prefix !== b.prefix) { + return a.prefix.localeCompare(b.prefix); + } + return a.name.localeCompare(b.name); + }); + let lastIcon = { + provider: "", + prefix: "", + name: "" + }; + icons.forEach((icon) => { + if (lastIcon.name === icon.name && lastIcon.prefix === icon.prefix && lastIcon.provider === icon.provider) { + return; + } + lastIcon = icon; + const provider = icon.provider; + const prefix = icon.prefix; + const name = icon.name; + const providerStorage = storage[provider] || (storage[provider] = /* @__PURE__ */ Object.create(null)); + const localStorage = providerStorage[prefix] || (providerStorage[prefix] = getStorage(provider, prefix)); + let list; + if (name in localStorage.icons) { + list = result.loaded; + } else if (prefix === "" || localStorage.missing.has(name)) { + list = result.missing; + } else { + list = result.pending; + } + const item = { + provider, + prefix, + name + }; + list.push(item); + }); + return result; + } + + function removeCallback(storages, id) { + storages.forEach((storage) => { + const items = storage.loaderCallbacks; + if (items) { + storage.loaderCallbacks = items.filter((row) => row.id !== id); + } + }); + } + function updateCallbacks(storage) { + if (!storage.pendingCallbacksFlag) { + storage.pendingCallbacksFlag = true; + setTimeout(() => { + storage.pendingCallbacksFlag = false; + const items = storage.loaderCallbacks ? storage.loaderCallbacks.slice(0) : []; + if (!items.length) { + return; + } + let hasPending = false; + const provider = storage.provider; + const prefix = storage.prefix; + items.forEach((item) => { + const icons = item.icons; + const oldLength = icons.pending.length; + icons.pending = icons.pending.filter((icon) => { + if (icon.prefix !== prefix) { + return true; + } + const name = icon.name; + if (storage.icons[name]) { + icons.loaded.push({ + provider, + prefix, + name + }); + } else if (storage.missing.has(name)) { + icons.missing.push({ + provider, + prefix, + name + }); + } else { + hasPending = true; + return true; + } + return false; + }); + if (icons.pending.length !== oldLength) { + if (!hasPending) { + removeCallback([storage], item.id); + } + item.callback( + icons.loaded.slice(0), + icons.missing.slice(0), + icons.pending.slice(0), + item.abort + ); + } + }); + }); + } + } + let idCounter = 0; + function storeCallback(callback, icons, pendingSources) { + const id = idCounter++; + const abort = removeCallback.bind(null, pendingSources, id); + if (!icons.pending.length) { + return abort; + } + const item = { + id, + icons, + callback, + abort + }; + pendingSources.forEach((storage) => { + (storage.loaderCallbacks || (storage.loaderCallbacks = [])).push(item); + }); + return abort; + } + + const storage = /* @__PURE__ */ Object.create(null); + function setAPIModule(provider, item) { + storage[provider] = item; + } + function getAPIModule(provider) { + return storage[provider] || storage[""]; + } + + function listToIcons(list, validate = true, simpleNames = false) { + const result = []; + list.forEach((item) => { + const icon = typeof item === "string" ? stringToIcon(item, validate, simpleNames) : item; + if (icon) { + result.push(icon); + } + }); + return result; + } + + // src/config.ts + var defaultConfig = { + resources: [], + index: 0, + timeout: 2e3, + rotate: 750, + random: false, + dataAfterTimeout: false + }; + + // src/query.ts + function sendQuery(config, payload, query, done) { + const resourcesCount = config.resources.length; + const startIndex = config.random ? Math.floor(Math.random() * resourcesCount) : config.index; + let resources; + if (config.random) { + let list = config.resources.slice(0); + resources = []; + while (list.length > 1) { + const nextIndex = Math.floor(Math.random() * list.length); + resources.push(list[nextIndex]); + list = list.slice(0, nextIndex).concat(list.slice(nextIndex + 1)); + } + resources = resources.concat(list); + } else { + resources = config.resources.slice(startIndex).concat(config.resources.slice(0, startIndex)); + } + const startTime = Date.now(); + let status = "pending"; + let queriesSent = 0; + let lastError; + let timer = null; + let queue = []; + let doneCallbacks = []; + if (typeof done === "function") { + doneCallbacks.push(done); + } + function resetTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + function abort() { + if (status === "pending") { + status = "aborted"; + } + resetTimer(); + queue.forEach((item) => { + if (item.status === "pending") { + item.status = "aborted"; + } + }); + queue = []; + } + function subscribe(callback, overwrite) { + if (overwrite) { + doneCallbacks = []; + } + if (typeof callback === "function") { + doneCallbacks.push(callback); + } + } + function getQueryStatus() { + return { + startTime, + payload, + status, + queriesSent, + queriesPending: queue.length, + subscribe, + abort + }; + } + function failQuery() { + status = "failed"; + doneCallbacks.forEach((callback) => { + callback(void 0, lastError); + }); + } + function clearQueue() { + queue.forEach((item) => { + if (item.status === "pending") { + item.status = "aborted"; + } + }); + queue = []; + } + function moduleResponse(item, response, data) { + const isError = response !== "success"; + queue = queue.filter((queued) => queued !== item); + switch (status) { + case "pending": + break; + case "failed": + if (isError || !config.dataAfterTimeout) { + return; + } + break; + default: + return; + } + if (response === "abort") { + lastError = data; + failQuery(); + return; + } + if (isError) { + lastError = data; + if (!queue.length) { + if (!resources.length) { + failQuery(); + } else { + execNext(); + } + } + return; + } + resetTimer(); + clearQueue(); + if (!config.random) { + const index = config.resources.indexOf(item.resource); + if (index !== -1 && index !== config.index) { + config.index = index; + } + } + status = "completed"; + doneCallbacks.forEach((callback) => { + callback(data); + }); + } + function execNext() { + if (status !== "pending") { + return; + } + resetTimer(); + const resource = resources.shift(); + if (resource === void 0) { + if (queue.length) { + timer = setTimeout(() => { + resetTimer(); + if (status === "pending") { + clearQueue(); + failQuery(); + } + }, config.timeout); + return; + } + failQuery(); + return; + } + const item = { + status: "pending", + resource, + callback: (status2, data) => { + moduleResponse(item, status2, data); + } + }; + queue.push(item); + queriesSent++; + timer = setTimeout(execNext, config.rotate); + query(resource, payload, item.callback); + } + setTimeout(execNext); + return getQueryStatus; + } + + // src/index.ts + function initRedundancy(cfg) { + const config = { + ...defaultConfig, + ...cfg + }; + let queries = []; + function cleanup() { + queries = queries.filter((item) => item().status === "pending"); + } + function query(payload, queryCallback, doneCallback) { + const query2 = sendQuery( + config, + payload, + queryCallback, + (data, error) => { + cleanup(); + if (doneCallback) { + doneCallback(data, error); + } + } + ); + queries.push(query2); + return query2; + } + function find(callback) { + return queries.find((value) => { + return callback(value); + }) || null; + } + const instance = { + query, + find, + setIndex: (index) => { + config.index = index; + }, + getIndex: () => config.index, + cleanup + }; + return instance; + } + + function createAPIConfig(source) { + let resources; + if (typeof source.resources === "string") { + resources = [source.resources]; + } else { + resources = source.resources; + if (!(resources instanceof Array) || !resources.length) { + return null; + } + } + const result = { + // API hosts + resources, + // Root path + path: source.path || "/", + // URL length limit + maxURL: source.maxURL || 500, + // Timeout before next host is used. + rotate: source.rotate || 750, + // Timeout before failing query. + timeout: source.timeout || 5e3, + // Randomise default API end point. + random: source.random === true, + // Start index + index: source.index || 0, + // Receive data after time out (used if time out kicks in first, then API module sends data anyway). + dataAfterTimeout: source.dataAfterTimeout !== false + }; + return result; + } + const configStorage = /* @__PURE__ */ Object.create(null); + const fallBackAPISources = [ + "https://api.simplesvg.com", + "https://api.unisvg.com" + ]; + const fallBackAPI = []; + while (fallBackAPISources.length > 0) { + if (fallBackAPISources.length === 1) { + fallBackAPI.push(fallBackAPISources.shift()); + } else { + if (Math.random() > 0.5) { + fallBackAPI.push(fallBackAPISources.shift()); + } else { + fallBackAPI.push(fallBackAPISources.pop()); + } + } + } + configStorage[""] = createAPIConfig({ + resources: ["https://api.iconify.design"].concat(fallBackAPI) + }); + function addAPIProvider(provider, customConfig) { + const config = createAPIConfig(customConfig); + if (config === null) { + return false; + } + configStorage[provider] = config; + return true; + } + function getAPIConfig(provider) { + return configStorage[provider]; + } + function listAPIProviders() { + return Object.keys(configStorage); + } + + function emptyCallback$1() { + } + const redundancyCache = /* @__PURE__ */ Object.create(null); + function getRedundancyCache(provider) { + if (!redundancyCache[provider]) { + const config = getAPIConfig(provider); + if (!config) { + return; + } + const redundancy = initRedundancy(config); + const cachedReundancy = { + config, + redundancy + }; + redundancyCache[provider] = cachedReundancy; + } + return redundancyCache[provider]; + } + function sendAPIQuery(target, query, callback) { + let redundancy; + let send; + if (typeof target === "string") { + const api = getAPIModule(target); + if (!api) { + callback(void 0, 424); + return emptyCallback$1; + } + send = api.send; + const cached = getRedundancyCache(target); + if (cached) { + redundancy = cached.redundancy; + } + } else { + const config = createAPIConfig(target); + if (config) { + redundancy = initRedundancy(config); + const moduleKey = target.resources ? target.resources[0] : ""; + const api = getAPIModule(moduleKey); + if (api) { + send = api.send; + } + } + } + if (!redundancy || !send) { + callback(void 0, 424); + return emptyCallback$1; + } + return redundancy.query(query, send, callback)().abort; + } + + function emptyCallback() { + } + function loadedNewIcons(storage) { + if (!storage.iconsLoaderFlag) { + storage.iconsLoaderFlag = true; + setTimeout(() => { + storage.iconsLoaderFlag = false; + updateCallbacks(storage); + }); + } + } + function checkIconNamesForAPI(icons) { + const valid = []; + const invalid = []; + icons.forEach((name) => { + (name.match(matchIconName) ? valid : invalid).push(name); + }); + return { + valid, + invalid + }; + } + function parseLoaderResponse(storage, icons, data) { + function checkMissing() { + const pending = storage.pendingIcons; + icons.forEach((name) => { + if (pending) { + pending.delete(name); + } + if (!storage.icons[name]) { + storage.missing.add(name); + } + }); + } + if (data && typeof data === "object") { + try { + const parsed = addIconSet(storage, data); + if (!parsed.length) { + checkMissing(); + return; + } + } catch (err) { + console.error(err); + } + } + checkMissing(); + loadedNewIcons(storage); + } + function parsePossiblyAsyncResponse(response, callback) { + if (response instanceof Promise) { + response.then((data) => { + callback(data); + }).catch(() => { + callback(null); + }); + } else { + callback(response); + } + } + function loadNewIcons(storage, icons) { + if (!storage.iconsToLoad) { + storage.iconsToLoad = icons; + } else { + storage.iconsToLoad = storage.iconsToLoad.concat(icons).sort(); + } + if (!storage.iconsQueueFlag) { + storage.iconsQueueFlag = true; + setTimeout(() => { + storage.iconsQueueFlag = false; + const { provider, prefix } = storage; + const icons2 = storage.iconsToLoad; + delete storage.iconsToLoad; + if (!icons2 || !icons2.length) { + return; + } + const customIconLoader = storage.loadIcon; + if (storage.loadIcons && (icons2.length > 1 || !customIconLoader)) { + parsePossiblyAsyncResponse( + storage.loadIcons(icons2, prefix, provider), + (data) => { + parseLoaderResponse(storage, icons2, data); + } + ); + return; + } + if (customIconLoader) { + icons2.forEach((name) => { + const response = customIconLoader(name, prefix, provider); + parsePossiblyAsyncResponse(response, (data) => { + const iconSet = data ? { + prefix, + icons: { + [name]: data + } + } : null; + parseLoaderResponse(storage, [name], iconSet); + }); + }); + return; + } + const { valid, invalid } = checkIconNamesForAPI(icons2); + if (invalid.length) { + parseLoaderResponse(storage, invalid, null); + } + if (!valid.length) { + return; + } + const api = prefix.match(matchIconName) ? getAPIModule(provider) : null; + if (!api) { + parseLoaderResponse(storage, valid, null); + return; + } + const params = api.prepare(provider, prefix, valid); + params.forEach((item) => { + sendAPIQuery(provider, item, (data) => { + parseLoaderResponse(storage, item.icons, data); + }); + }); + }); + } + } + const loadIcons = (icons, callback) => { + const cleanedIcons = listToIcons(icons, true, allowSimpleNames()); + const sortedIcons = sortIcons(cleanedIcons); + if (!sortedIcons.pending.length) { + let callCallback = true; + if (callback) { + setTimeout(() => { + if (callCallback) { + callback( + sortedIcons.loaded, + sortedIcons.missing, + sortedIcons.pending, + emptyCallback + ); + } + }); + } + return () => { + callCallback = false; + }; + } + const newIcons = /* @__PURE__ */ Object.create(null); + const sources = []; + let lastProvider, lastPrefix; + sortedIcons.pending.forEach((icon) => { + const { provider, prefix } = icon; + if (prefix === lastPrefix && provider === lastProvider) { + return; + } + lastProvider = provider; + lastPrefix = prefix; + sources.push(getStorage(provider, prefix)); + const providerNewIcons = newIcons[provider] || (newIcons[provider] = /* @__PURE__ */ Object.create(null)); + if (!providerNewIcons[prefix]) { + providerNewIcons[prefix] = []; + } + }); + sortedIcons.pending.forEach((icon) => { + const { provider, prefix, name } = icon; + const storage = getStorage(provider, prefix); + const pendingQueue = storage.pendingIcons || (storage.pendingIcons = /* @__PURE__ */ new Set()); + if (!pendingQueue.has(name)) { + pendingQueue.add(name); + newIcons[provider][prefix].push(name); + } + }); + sources.forEach((storage) => { + const list = newIcons[storage.provider][storage.prefix]; + if (list.length) { + loadNewIcons(storage, list); + } + }); + return callback ? storeCallback(callback, sortedIcons, sources) : emptyCallback; + }; + const loadIcon = (icon) => { + return new Promise((fulfill, reject) => { + const iconObj = typeof icon === "string" ? stringToIcon(icon, true) : icon; + if (!iconObj) { + reject(icon); + return; + } + loadIcons([iconObj || icon], (loaded) => { + if (loaded.length && iconObj) { + const data = getIconData(iconObj); + if (data) { + fulfill({ + ...defaultIconProps, + ...data + }); + return; + } + } + reject(icon); + }); + }); + }; + + /** + * Test icon string + */ + function testIconObject(value) { + try { + const obj = typeof value === 'string' ? JSON.parse(value) : value; + if (typeof obj.body === 'string') { + return { + ...obj, + }; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + } + + /** + * Parse icon value, load if needed + */ + function parseIconValue(value, onload) { + if (typeof value === 'object') { + const data = testIconObject(value); + return { + data, + value, + }; + } + if (typeof value !== 'string') { + // Invalid value + return { + value, + }; + } + // Check for JSON + if (value.includes('{')) { + const data = testIconObject(value); + if (data) { + return { + data, + value, + }; + } + } + // Parse icon name + const name = stringToIcon(value, true, true); + if (!name) { + return { + value, + }; + } + // Valid icon name: check if data is available + const data = getIconData(name); + // Icon data exists or icon has no prefix. Do not load icon from API if icon has no prefix + if (data !== undefined || !name.prefix) { + return { + value, + name, + data, // could be 'null' -> icon is missing + }; + } + // Load icon + const loading = loadIcons([name], () => onload(value, name, getIconData(name))); + return { + value, + name, + loading, + }; + } + + // Check for Safari + let isBuggedSafari = false; + try { + isBuggedSafari = navigator.vendor.indexOf('Apple') === 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + /** + * Get render mode + */ + function getRenderMode(body, mode) { + switch (mode) { + // Force mode + case 'svg': + case 'bg': + case 'mask': + return mode; + } + // Check for animation, use 'style' for animated icons, unless browser is Safari + // (only , which should be ignored or animations start with ' + return 'svg'; + } + // Use background or mask + return body.indexOf('currentColor') === -1 ? 'bg' : 'mask'; + } + + const unitsSplit = /(-?[0-9.]*[0-9]+[0-9.]*)/g; + const unitsTest = /^-?[0-9.]*[0-9]+[0-9.]*$/g; + function calculateSize(size, ratio, precision) { + if (ratio === 1) { + return size; + } + precision = precision || 100; + if (typeof size === "number") { + return Math.ceil(size * ratio * precision) / precision; + } + if (typeof size !== "string") { + return size; + } + const oldParts = size.split(unitsSplit); + if (oldParts === null || !oldParts.length) { + return size; + } + const newParts = []; + let code = oldParts.shift(); + let isNumber = unitsTest.test(code); + while (true) { + if (isNumber) { + const num = parseFloat(code); + if (isNaN(num)) { + newParts.push(code); + } else { + newParts.push(Math.ceil(num * ratio * precision) / precision); + } + } else { + newParts.push(code); + } + code = oldParts.shift(); + if (code === void 0) { + return newParts.join(""); + } + isNumber = !isNumber; + } + } + + function splitSVGDefs(content, tag = "defs") { + let defs = ""; + const index = content.indexOf("<" + tag); + while (index >= 0) { + const start = content.indexOf(">", index); + const end = content.indexOf("", end); + if (endEnd === -1) { + break; + } + defs += content.slice(start + 1, end).trim(); + content = content.slice(0, index).trim() + content.slice(endEnd + 1); + } + return { + defs, + content + }; + } + function mergeDefsAndContent(defs, content) { + return defs ? "" + defs + "" + content : content; + } + function wrapSVGContent(body, start, end) { + const split = splitSVGDefs(body); + return mergeDefsAndContent(split.defs, start + split.content + end); + } + + const isUnsetKeyword = (value) => value === "unset" || value === "undefined" || value === "none"; + function iconToSVG(icon, customisations) { + const fullIcon = { + ...defaultIconProps, + ...icon + }; + const fullCustomisations = { + ...defaultIconCustomisations, + ...customisations + }; + const box = { + left: fullIcon.left, + top: fullIcon.top, + width: fullIcon.width, + height: fullIcon.height + }; + let body = fullIcon.body; + [fullIcon, fullCustomisations].forEach((props) => { + const transformations = []; + const hFlip = props.hFlip; + const vFlip = props.vFlip; + let rotation = props.rotate; + if (hFlip) { + if (vFlip) { + rotation += 2; + } else { + transformations.push( + "translate(" + (box.width + box.left).toString() + " " + (0 - box.top).toString() + ")" + ); + transformations.push("scale(-1 1)"); + box.top = box.left = 0; + } + } else if (vFlip) { + transformations.push( + "translate(" + (0 - box.left).toString() + " " + (box.height + box.top).toString() + ")" + ); + transformations.push("scale(1 -1)"); + box.top = box.left = 0; + } + let tempValue; + if (rotation < 0) { + rotation -= Math.floor(rotation / 4) * 4; + } + rotation = rotation % 4; + switch (rotation) { + case 1: + tempValue = box.height / 2 + box.top; + transformations.unshift( + "rotate(90 " + tempValue.toString() + " " + tempValue.toString() + ")" + ); + break; + case 2: + transformations.unshift( + "rotate(180 " + (box.width / 2 + box.left).toString() + " " + (box.height / 2 + box.top).toString() + ")" + ); + break; + case 3: + tempValue = box.width / 2 + box.left; + transformations.unshift( + "rotate(-90 " + tempValue.toString() + " " + tempValue.toString() + ")" + ); + break; + } + if (rotation % 2 === 1) { + if (box.left !== box.top) { + tempValue = box.left; + box.left = box.top; + box.top = tempValue; + } + if (box.width !== box.height) { + tempValue = box.width; + box.width = box.height; + box.height = tempValue; + } + } + if (transformations.length) { + body = wrapSVGContent( + body, + '', + "" + ); + } + }); + const customisationsWidth = fullCustomisations.width; + const customisationsHeight = fullCustomisations.height; + const boxWidth = box.width; + const boxHeight = box.height; + let width; + let height; + if (customisationsWidth === null) { + height = customisationsHeight === null ? "1em" : customisationsHeight === "auto" ? boxHeight : customisationsHeight; + width = calculateSize(height, boxWidth / boxHeight); + } else { + width = customisationsWidth === "auto" ? boxWidth : customisationsWidth; + height = customisationsHeight === null ? calculateSize(width, boxHeight / boxWidth) : customisationsHeight === "auto" ? boxHeight : customisationsHeight; + } + const attributes = {}; + const setAttr = (prop, value) => { + if (!isUnsetKeyword(value)) { + attributes[prop] = value.toString(); + } + }; + setAttr("width", width); + setAttr("height", height); + const viewBox = [box.left, box.top, boxWidth, boxHeight]; + attributes.viewBox = viewBox.join(" "); + return { + attributes, + viewBox, + body + }; + } + + function iconToHTML(body, attributes) { + let renderAttribsHTML = body.indexOf("xlink:") === -1 ? "" : ' xmlns:xlink="http://www.w3.org/1999/xlink"'; + for (const attr in attributes) { + renderAttribsHTML += " " + attr + '="' + attributes[attr] + '"'; + } + return '" + body + ""; + } + + function encodeSVGforURL(svg) { + return svg.replace(/"/g, "'").replace(/%/g, "%25").replace(/#/g, "%23").replace(//g, "%3E").replace(/\s+/g, " "); + } + function svgToData(svg) { + return "data:image/svg+xml," + encodeSVGforURL(svg); + } + function svgToURL(svg) { + return 'url("' + svgToData(svg) + '")'; + } + + const detectFetch = () => { + let callback; + try { + callback = fetch; + if (typeof callback === "function") { + return callback; + } + } catch (err) { + } + }; + let fetchModule = detectFetch(); + function setFetch(fetch2) { + fetchModule = fetch2; + } + function getFetch() { + return fetchModule; + } + function calculateMaxLength(provider, prefix) { + const config = getAPIConfig(provider); + if (!config) { + return 0; + } + let result; + if (!config.maxURL) { + result = 0; + } else { + let maxHostLength = 0; + config.resources.forEach((item) => { + const host = item; + maxHostLength = Math.max(maxHostLength, host.length); + }); + const url = prefix + ".json?icons="; + result = config.maxURL - maxHostLength - config.path.length - url.length; + } + return result; + } + function shouldAbort(status) { + return status === 404; + } + const prepare = (provider, prefix, icons) => { + const results = []; + const maxLength = calculateMaxLength(provider, prefix); + const type = "icons"; + let item = { + type, + provider, + prefix, + icons: [] + }; + let length = 0; + icons.forEach((name, index) => { + length += name.length + 1; + if (length >= maxLength && index > 0) { + results.push(item); + item = { + type, + provider, + prefix, + icons: [] + }; + length = name.length; + } + item.icons.push(name); + }); + results.push(item); + return results; + }; + function getPath(provider) { + if (typeof provider === "string") { + const config = getAPIConfig(provider); + if (config) { + return config.path; + } + } + return "/"; + } + const send = (host, params, callback) => { + if (!fetchModule) { + callback("abort", 424); + return; + } + let path = getPath(params.provider); + switch (params.type) { + case "icons": { + const prefix = params.prefix; + const icons = params.icons; + const iconsList = icons.join(","); + const urlParams = new URLSearchParams({ + icons: iconsList + }); + path += prefix + ".json?" + urlParams.toString(); + break; + } + case "custom": { + const uri = params.uri; + path += uri.slice(0, 1) === "/" ? uri.slice(1) : uri; + break; + } + default: + callback("abort", 400); + return; + } + let defaultError = 503; + fetchModule(host + path).then((response) => { + const status = response.status; + if (status !== 200) { + setTimeout(() => { + callback(shouldAbort(status) ? "abort" : "next", status); + }); + return; + } + defaultError = 501; + return response.json(); + }).then((data) => { + if (typeof data !== "object" || data === null) { + setTimeout(() => { + if (data === 404) { + callback("abort", data); + } else { + callback("next", defaultError); + } + }); + return; + } + setTimeout(() => { + callback("success", data); + }); + }).catch(() => { + callback("next", defaultError); + }); + }; + const fetchAPIModule = { + prepare, + send + }; + + function setCustomIconsLoader(loader, prefix, provider) { + getStorage(provider || "", prefix).loadIcons = loader; + } + function setCustomIconLoader(loader, prefix, provider) { + getStorage(provider || "", prefix).loadIcon = loader; + } + + /** + * Attribute to add + */ + const nodeAttr = 'data-style'; + /** + * Custom style to add to each node + */ + let customStyle = ''; + /** + * Set custom style to add to all components + * + * Affects only components rendered after function call + */ + function appendCustomStyle(style) { + customStyle = style; + } + /** + * Add/update style node + */ + function updateStyle(parent, inline) { + // Get node, create if needed + let styleNode = Array.from(parent.childNodes).find((node) => node.hasAttribute && + node.hasAttribute(nodeAttr)); + if (!styleNode) { + styleNode = document.createElement('style'); + styleNode.setAttribute(nodeAttr, nodeAttr); + parent.appendChild(styleNode); + } + // Update content + styleNode.textContent = + ':host{display:inline-block;vertical-align:' + + (inline ? '-0.125em' : '0') + + '}span,svg{display:block;margin:auto}' + + customStyle; + } + + // Core + /** + * Get functions and initialise stuff + */ + function exportFunctions() { + /** + * Initialise stuff + */ + // Set API module + setAPIModule('', fetchAPIModule); + // Allow simple icon names + allowSimpleNames(true); + let _window; + try { + _window = window; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + if (_window) { + // Load icons from global "IconifyPreload" + if (_window.IconifyPreload !== void 0) { + const preload = _window.IconifyPreload; + const err = 'Invalid IconifyPreload syntax.'; + if (typeof preload === 'object' && preload !== null) { + (preload instanceof Array ? preload : [preload]).forEach((item) => { + try { + if ( + // Check if item is an object and not null/array + typeof item !== 'object' || + item === null || + item instanceof Array || + // Check for 'icons' and 'prefix' + typeof item.icons !== 'object' || + typeof item.prefix !== 'string' || + // Add icon set + !addCollection(item)) { + console.error(err); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (e) { + console.error(err); + } + }); + } + } + // Set API from global "IconifyProviders" + if (_window.IconifyProviders !== void 0) { + const providers = _window.IconifyProviders; + if (typeof providers === 'object' && providers !== null) { + for (const key in providers) { + const err = 'IconifyProviders[' + key + '] is invalid.'; + try { + const value = providers[key]; + if (typeof value !== 'object' || + !value || + value.resources === void 0) { + continue; + } + if (!addAPIProvider(key, value)) { + console.error(err); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (e) { + console.error(err); + } + } + } + } + } + const _api = { + getAPIConfig, + setAPIModule, + sendAPIQuery, + setFetch, + getFetch, + listAPIProviders, + }; + return { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + enableCache: (storage) => { + // No longer used + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + disableCache: (storage) => { + // No longer used + }, + iconLoaded, + iconExists: iconLoaded, // deprecated, kept to avoid breaking changes + getIcon, + listIcons, + addIcon, + addCollection, + calculateSize, + buildIcon: iconToSVG, + iconToHTML, + svgToURL, + loadIcons, + loadIcon, + addAPIProvider, + setCustomIconLoader, + setCustomIconsLoader, + appendCustomStyle, + _api, + }; + } + + // List of properties to apply + const monotoneProps = { + 'background-color': 'currentColor', + }; + const coloredProps = { + 'background-color': 'transparent', + }; + // Dynamically add common props to variables above + const propsToAdd = { + image: 'var(--svg)', + repeat: 'no-repeat', + size: '100% 100%', + }; + const propsToAddTo = { + '-webkit-mask': monotoneProps, + 'mask': monotoneProps, + 'background': coloredProps, + }; + for (const prefix in propsToAddTo) { + const list = propsToAddTo[prefix]; + for (const prop in propsToAdd) { + list[prefix + '-' + prop] = propsToAdd[prop]; + } + } + /** + * Fix size: add 'px' to numbers + */ + function fixSize(value) { + return value ? value + (value.match(/^[-0-9.]+$/) ? 'px' : '') : 'inherit'; + } + /** + * Render node as + */ + function renderSPAN(data, icon, useMask) { + const node = document.createElement('span'); + // Body + let body = data.body; + if (body.indexOf(''; + } + // Generate SVG as URL + const renderAttribs = data.attributes; + const html = iconToHTML(body, { + ...renderAttribs, + width: icon.width + '', + height: icon.height + '', + }); + const url = svgToURL(html); + // Generate style + const svgStyle = node.style; + const styles = { + '--svg': url, + 'width': fixSize(renderAttribs.width), + 'height': fixSize(renderAttribs.height), + ...(useMask ? monotoneProps : coloredProps), + }; + // Apply style + for (const prop in styles) { + svgStyle.setProperty(prop, styles[prop]); + } + return node; + } + + let policy; + function createPolicy() { + try { + policy = window.trustedTypes.createPolicy("iconify", { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + createHTML: (s) => s + }); + } catch (err) { + policy = null; + } + } + function cleanUpInnerHTML(html) { + if (policy === void 0) { + createPolicy(); + } + return policy ? policy.createHTML(html) : html; + } + + /** + * Render node as + */ + function renderSVG(data) { + const node = document.createElement('span'); + // Add style if needed + const attr = data.attributes; + let style = ''; + if (!attr.width) { + style = 'width: inherit;'; + } + if (!attr.height) { + style += 'height: inherit;'; + } + if (style) { + attr.style = style; + } + // Generate SVG + const html = iconToHTML(data.body, attr); + node.innerHTML = cleanUpInnerHTML(html); + return node.firstChild; + } + + /** + * Find icon node + */ + function findIconElement(parent) { + return Array.from(parent.childNodes).find((node) => { + const tag = node.tagName && + node.tagName.toUpperCase(); + return tag === 'SPAN' || tag === 'SVG'; + }); + } + /** + * Render icon + */ + function renderIcon(parent, state) { + const iconData = state.icon.data; + const customisations = state.customisations; + // Render icon + const renderData = iconToSVG(iconData, customisations); + if (customisations.preserveAspectRatio) { + renderData.attributes['preserveAspectRatio'] = + customisations.preserveAspectRatio; + } + const mode = state.renderedMode; + let node; + switch (mode) { + case 'svg': + node = renderSVG(renderData); + break; + default: + node = renderSPAN(renderData, { + ...defaultIconProps, + ...iconData, + }, mode === 'mask'); + } + // Set element + const oldNode = findIconElement(parent); + if (oldNode) { + // Replace old element + if (node.tagName === 'SPAN' && oldNode.tagName === node.tagName) { + // Swap style instead of whole node + oldNode.setAttribute('style', node.getAttribute('style')); + } + else { + parent.replaceChild(node, oldNode); + } + } + else { + // Add new element + parent.appendChild(node); + } + } + + /** + * Set state to PendingState + */ + function setPendingState(icon, inline, lastState) { + const lastRender = lastState && + (lastState.rendered + ? lastState + : lastState.lastRender); + return { + rendered: false, + inline, + icon, + lastRender, + }; + } + + /** + * Register 'iconify-icon' component, if it does not exist + */ + function defineIconifyIcon(name = 'iconify-icon') { + // Check for custom elements registry and HTMLElement + let customElements; + let ParentClass; + try { + customElements = window.customElements; + ParentClass = window.HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + return; + } + // Make sure registry and HTMLElement exist + if (!customElements || !ParentClass) { + return; + } + // Check for duplicate + const ConflictingClass = customElements.get(name); + if (ConflictingClass) { + return ConflictingClass; + } + // All attributes + const attributes = [ + // Icon + 'icon', + // Mode + 'mode', + 'inline', + 'noobserver', + // Customisations + 'width', + 'height', + 'rotate', + 'flip', + ]; + /** + * Component class + */ + const IconifyIcon = class extends ParentClass { + // Root + _shadowRoot; + // Initialised + _initialised = false; + // Icon state + _state; + // Attributes check queued + _checkQueued = false; + // Connected + _connected = false; + // Observer + _observer = null; + _visible = true; + /** + * Constructor + */ + constructor() { + super(); + // Attach shadow DOM + const root = (this._shadowRoot = this.attachShadow({ + mode: 'open', + })); + // Add style + const inline = this.hasAttribute('inline'); + updateStyle(root, inline); + // Create empty state + this._state = setPendingState({ + value: '', + }, inline); + // Queue icon render + this._queueCheck(); + } + /** + * Connected to DOM + */ + connectedCallback() { + this._connected = true; + this.startObserver(); + } + /** + * Disconnected from DOM + */ + disconnectedCallback() { + this._connected = false; + this.stopObserver(); + } + /** + * Observed attributes + */ + static get observedAttributes() { + return attributes.slice(0); + } + /** + * Observed properties that are different from attributes + * + * Experimental! Need to test with various frameworks that support it + */ + /* + static get properties() { + return { + inline: { + type: Boolean, + reflect: true, + }, + // Not listing other attributes because they are strings or combination + // of string and another type. Cannot have multiple types + }; + } + */ + /** + * Attribute has changed + */ + attributeChangedCallback(name) { + switch (name) { + case 'inline': { + // Update immediately: not affected by other attributes + const newInline = this.hasAttribute('inline'); + const state = this._state; + if (newInline !== state.inline) { + // Update style if inline mode changed + state.inline = newInline; + updateStyle(this._shadowRoot, newInline); + } + break; + } + case 'noobserver': { + const value = this.hasAttribute('noobserver'); + if (value) { + this.startObserver(); + } + else { + this.stopObserver(); + } + break; + } + default: + // Queue check for other attributes + this._queueCheck(); + } + } + /** + * Get/set icon + */ + get icon() { + const value = this.getAttribute('icon'); + if (value && value.slice(0, 1) === '{') { + try { + return JSON.parse(value); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + } + return value; + } + set icon(value) { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + this.setAttribute('icon', value); + } + /** + * Get/set inline + */ + get inline() { + return this.hasAttribute('inline'); + } + set inline(value) { + if (value) { + this.setAttribute('inline', 'true'); + } + else { + this.removeAttribute('inline'); + } + } + /** + * Get/set observer + */ + get observer() { + return this.hasAttribute('observer'); + } + set observer(value) { + if (value) { + this.setAttribute('observer', 'true'); + } + else { + this.removeAttribute('observer'); + } + } + /** + * Restart animation + */ + restartAnimation() { + const state = this._state; + if (state.rendered) { + const root = this._shadowRoot; + if (state.renderedMode === 'svg') { + // Update root node + try { + root.lastChild.setCurrentTime(0); + return; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // Failed: setCurrentTime() is not supported + } + } + renderIcon(root, state); + } + } + /** + * Get status + */ + get status() { + const state = this._state; + return state.rendered + ? 'rendered' + : state.icon.data === null + ? 'failed' + : 'loading'; + } + /** + * Queue attributes re-check + */ + _queueCheck() { + if (!this._checkQueued) { + this._checkQueued = true; + setTimeout(() => { + this._check(); + }); + } + } + /** + * Check for changes + */ + _check() { + if (!this._checkQueued) { + return; + } + this._checkQueued = false; + const state = this._state; + // Get icon + const newIcon = this.getAttribute('icon'); + if (newIcon !== state.icon.value) { + this._iconChanged(newIcon); + return; + } + // Ignore other attributes if icon is not rendered + if (!state.rendered || !this._visible) { + return; + } + // Check for mode and attribute changes + const mode = this.getAttribute('mode'); + const customisations = getCustomisations(this); + if (state.attrMode !== mode || + haveCustomisationsChanged(state.customisations, customisations) || + !findIconElement(this._shadowRoot)) { + this._renderIcon(state.icon, customisations, mode); + } + } + /** + * Icon value has changed + */ + _iconChanged(newValue) { + const icon = parseIconValue(newValue, (value, name, data) => { + // Asynchronous callback: re-check values to make sure stuff wasn't changed + const state = this._state; + if (state.rendered || this.getAttribute('icon') !== value) { + // Icon data is already available or icon attribute was changed + return; + } + // Change icon + const icon = { + value, + name, + data, + }; + if (icon.data) { + // Render icon + this._gotIconData(icon); + } + else { + // Nothing to render: update icon in state + state.icon = icon; + } + }); + if (icon.data) { + // Icon is ready to render + this._gotIconData(icon); + } + else { + // Pending icon + this._state = setPendingState(icon, this._state.inline, this._state); + } + } + /** + * Force render icon on state change + */ + _forceRender() { + if (!this._visible) { + // Remove icon + const node = findIconElement(this._shadowRoot); + if (node) { + this._shadowRoot.removeChild(node); + } + return; + } + // Re-render icon + this._queueCheck(); + } + /** + * Got new icon data, icon is ready to (re)render + */ + _gotIconData(icon) { + this._checkQueued = false; + this._renderIcon(icon, getCustomisations(this), this.getAttribute('mode')); + } + /** + * Re-render based on icon data + */ + _renderIcon(icon, customisations, attrMode) { + // Get mode + const renderedMode = getRenderMode(icon.data.body, attrMode); + // Inline was not changed + const inline = this._state.inline; + // Set state and render + renderIcon(this._shadowRoot, (this._state = { + rendered: true, + icon, + inline, + customisations, + attrMode, + renderedMode, + })); + } + /** + * Start observer + */ + startObserver() { + if (!this._observer && !this.hasAttribute('noobserver')) { + try { + this._observer = new IntersectionObserver((entries) => { + const intersecting = entries.some((entry) => entry.isIntersecting); + if (intersecting !== this._visible) { + this._visible = intersecting; + this._forceRender(); + } + }); + this._observer.observe(this); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // Something went wrong, possibly observer is not supported + if (this._observer) { + try { + this._observer.disconnect(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + this._observer = null; + } + } + } + } + /** + * Stop observer + */ + stopObserver() { + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + this._visible = true; + if (this._connected) { + // Render icon + this._forceRender(); + } + } + } + }; + // Add getters and setters + attributes.forEach((attr) => { + if (!(attr in IconifyIcon.prototype)) { + Object.defineProperty(IconifyIcon.prototype, attr, { + get: function () { + return this.getAttribute(attr); + }, + set: function (value) { + if (value !== null) { + this.setAttribute(attr, value); + } + else { + this.removeAttribute(attr); + } + }, + }); + } + }); + // Add exported functions: both as static and instance methods + const functions = exportFunctions(); + for (const key in functions) { + IconifyIcon[key] = IconifyIcon.prototype[key] = functions[key]; + } + // Define new component + customElements.define(name, IconifyIcon); + return IconifyIcon; + } + + // Register component + defineIconifyIcon(); + +})(); diff --git a/web_iconify/static/lib/iconify/iconify-icon.min.js b/web_iconify/static/lib/iconify/iconify-icon.min.js new file mode 100644 index 00000000000..712364e3788 --- /dev/null +++ b/web_iconify/static/lib/iconify/iconify-icon.min.js @@ -0,0 +1,12 @@ +/** +* (c) Iconify +* +* For the full copyright and license information, please view the license.txt +* files at https://github.com/iconify/iconify +* +* Licensed under MIT. +* +* @license MIT +* @version 2.3.0 +*/ +!function(){"use strict";const t=Object.freeze({left:0,top:0,width:16,height:16}),e=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),n=Object.freeze({...t,...e}),i=Object.freeze({...n,body:"",hidden:!1}),r=Object.freeze({width:null,height:null}),o=Object.freeze({...r,...e});const s=/[\s,]+/;const c={...o,preserveAspectRatio:""};function a(t){const e={...c},n=(e,n)=>t.getAttribute(e)||n;var i;return e.width=n("width",null),e.height=n("height",null),e.rotate=function(t,e=0){const n=t.replace(/^-?[0-9.]*/,"");function i(t){for(;t<0;)t+=4;return t%4}if(""===n){const e=parseInt(t);return isNaN(e)?0:i(e)}if(n!==t){let e=0;switch(n){case"%":e=25;break;case"deg":e=90}if(e){let r=parseFloat(t.slice(0,t.length-n.length));return isNaN(r)?0:(r/=e,r%1==0?i(r):0)}}return e}(n("rotate","")),i=e,n("flip","").split(s).forEach((t=>{switch(t.trim()){case"horizontal":i.hFlip=!0;break;case"vertical":i.vFlip=!0}})),e.preserveAspectRatio=n("preserveAspectRatio",n("preserveaspectratio","")),e}const u=/^[a-z0-9]+(-[a-z0-9]+)*$/,l=(t,e,n,i="")=>{const r=t.split(":");if("@"===t.slice(0,1)){if(r.length<2||r.length>3)return null;i=r.shift().slice(1)}if(r.length>3||!r.length)return null;if(r.length>1){const t=r.pop(),n=r.pop(),o={provider:r.length>0?r[0]:i,prefix:n,name:t};return e&&!f(o)?null:o}const o=r[0],s=o.split("-");if(s.length>1){const t={provider:i,prefix:s.shift(),name:s.join("-")};return e&&!f(t)?null:t}if(n&&""===i){const t={provider:i,prefix:"",name:o};return e&&!f(t,n)?null:t}return null},f=(t,e)=>!!t&&!(!(e&&""===t.prefix||t.prefix)||!t.name);function d(t,n){const r=function(t,e){const n={};!t.hFlip!=!e.hFlip&&(n.hFlip=!0),!t.vFlip!=!e.vFlip&&(n.vFlip=!0);const i=((t.rotate||0)+(e.rotate||0))%4;return i&&(n.rotate=i),n}(t,n);for(const o in i)o in e?o in t&&!(o in r)&&(r[o]=e[o]):o in n?r[o]=n[o]:o in t&&(r[o]=t[o]);return r}function h(t,e,n){const i=t.icons,r=t.aliases||Object.create(null);let o={};function s(t){o=d(i[t]||r[t],o)}return s(e),n.forEach(s),d(t,o)}function p(t,e){const n=[];if("object"!=typeof t||"object"!=typeof t.icons)return n;t.not_found instanceof Array&&t.not_found.forEach((t=>{e(t,null),n.push(t)}));const i=function(t,e){const n=t.icons,i=t.aliases||Object.create(null),r=Object.create(null);return Object.keys(n).concat(Object.keys(i)).forEach((function t(e){if(n[e])return r[e]=[];if(!(e in r)){r[e]=null;const n=i[e]&&i[e].parent,o=n&&t(n);o&&(r[e]=[n].concat(o))}return r[e]})),r}(t);for(const r in i){const o=i[r];o&&(e(r,h(t,r,o)),n.push(r))}return n}const g={provider:"",aliases:{},not_found:{},...t};function b(t,e){for(const n in e)if(n in t&&typeof t[n]!=typeof e[n])return!1;return!0}function v(t){if("object"!=typeof t||null===t)return null;const e=t;if("string"!=typeof e.prefix||!t.icons||"object"!=typeof t.icons)return null;if(!b(t,g))return null;const n=e.icons;for(const t in n){const e=n[t];if(!t||"string"!=typeof e.body||!b(e,i))return null}const r=e.aliases||Object.create(null);for(const t in r){const e=r[t],o=e.parent;if(!t||"string"!=typeof o||!n[o]&&!r[o]||!b(e,i))return null}return e}const m=Object.create(null);function y(t,e){const n=m[t]||(m[t]=Object.create(null));return n[e]||(n[e]=function(t,e){return{provider:t,prefix:e,icons:Object.create(null),missing:new Set}}(t,e))}function x(t,e){return v(e)?p(e,((e,n)=>{n?t.icons[e]=n:t.missing.add(e)})):[]}function _(t,e){let n=[];return("string"==typeof t?[t]:Object.keys(m)).forEach((t=>{("string"==typeof t&&"string"==typeof e?[e]:Object.keys(m[t]||{})).forEach((e=>{const i=y(t,e);n=n.concat(Object.keys(i.icons).map((n=>(""!==t?"@"+t+":":"")+e+":"+n)))}))})),n}let w=!1;function k(t){return"boolean"==typeof t&&(w=t),w}function A(t){const e="string"==typeof t?l(t,!0,w):t;if(e){const t=y(e.provider,e.prefix),n=e.name;return t.icons[n]||(t.missing.has(n)?null:void 0)}}function j(t,e){const n=l(t,!0,w);if(!n)return!1;const i=y(n.provider,n.prefix);return e?function(t,e,n){try{if("string"==typeof n.body)return t.icons[e]={...n},!0}catch(t){}return!1}(i,n.name,e):(i.missing.add(n.name),!0)}function O(t,e){if("object"!=typeof t)return!1;if("string"!=typeof e&&(e=t.provider||""),w&&!e&&!t.prefix){let e=!1;return v(t)&&(t.prefix="",p(t,((t,n)=>{j(t,n)&&(e=!0)}))),e}const n=t.prefix;if(!f({provider:e,prefix:n,name:"a"}))return!1;return!!x(y(e,n),t)}function C(t){return!!A(t)}function I(t){const e=A(t);return e?{...n,...e}:e}function E(t,e){t.forEach((t=>{const n=t.loaderCallbacks;n&&(t.loaderCallbacks=n.filter((t=>t.id!==e)))}))}let T=0;const F=Object.create(null);function R(t,e){F[t]=e}function S(t){return F[t]||F[""]}var L={resources:[],index:0,timeout:2e3,rotate:750,random:!1,dataAfterTimeout:!1};function P(t,e,n,i){const r=t.resources.length,o=t.random?Math.floor(Math.random()*r):t.index;let s;if(t.random){let e=t.resources.slice(0);for(s=[];e.length>1;){const t=Math.floor(Math.random()*e.length);s.push(e[t]),e=e.slice(0,t).concat(e.slice(t+1))}s=s.concat(e)}else s=t.resources.slice(o).concat(t.resources.slice(0,o));const c=Date.now();let a,u="pending",l=0,f=null,d=[],h=[];function p(){f&&(clearTimeout(f),f=null)}function g(){"pending"===u&&(u="aborted"),p(),d.forEach((t=>{"pending"===t.status&&(t.status="aborted")})),d=[]}function b(t,e){e&&(h=[]),"function"==typeof t&&h.push(t)}function v(){u="failed",h.forEach((t=>{t(void 0,a)}))}function m(){d.forEach((t=>{"pending"===t.status&&(t.status="aborted")})),d=[]}function y(){if("pending"!==u)return;p();const i=s.shift();if(void 0===i)return d.length?void(f=setTimeout((()=>{p(),"pending"===u&&(m(),v())}),t.timeout)):void v();const r={status:"pending",resource:i,callback:(e,n)=>{!function(e,n,i){const r="success"!==n;switch(d=d.filter((t=>t!==e)),u){case"pending":break;case"failed":if(r||!t.dataAfterTimeout)return;break;default:return}if("abort"===n)return a=i,void v();if(r)return a=i,void(d.length||(s.length?y():v()));if(p(),m(),!t.random){const n=t.resources.indexOf(e.resource);-1!==n&&n!==t.index&&(t.index=n)}u="completed",h.forEach((t=>{t(i)}))}(r,e,n)}};d.push(r),l++,f=setTimeout(y,t.rotate),n(i,e,r.callback)}return"function"==typeof i&&h.push(i),setTimeout(y),function(){return{startTime:c,payload:e,status:u,queriesSent:l,queriesPending:d.length,subscribe:b,abort:g}}}function M(t){const e={...L,...t};let n=[];function i(){n=n.filter((t=>"pending"===t().status))}return{query:function(t,r,o){const s=P(e,t,r,((t,e)=>{i(),o&&o(t,e)}));return n.push(s),s},find:function(t){return n.find((e=>t(e)))||null},setIndex:t=>{e.index=t},getIndex:()=>e.index,cleanup:i}}function N(t){let e;if("string"==typeof t.resources)e=[t.resources];else if(e=t.resources,!(e instanceof Array&&e.length))return null;return{resources:e,path:t.path||"/",maxURL:t.maxURL||500,rotate:t.rotate||750,timeout:t.timeout||5e3,random:!0===t.random,index:t.index||0,dataAfterTimeout:!1!==t.dataAfterTimeout}}const z=Object.create(null),Q=["https://api.simplesvg.com","https://api.unisvg.com"],q=[];for(;Q.length>0;)1===Q.length||Math.random()>.5?q.push(Q.shift()):q.push(Q.pop());function U(t,e){const n=N(e);return null!==n&&(z[t]=n,!0)}function D(t){return z[t]}function H(){return Object.keys(z)}function J(){}z[""]=N({resources:["https://api.iconify.design"].concat(q)});const $=Object.create(null);function B(t,e,n){let i,r;if("string"==typeof t){const e=S(t);if(!e)return n(void 0,424),J;r=e.send;const o=function(t){if(!$[t]){const e=D(t);if(!e)return;const n={config:e,redundancy:M(e)};$[t]=n}return $[t]}(t);o&&(i=o.redundancy)}else{const e=N(t);if(e){i=M(e);const n=S(t.resources?t.resources[0]:"");n&&(r=n.send)}}return i&&r?i.query(e,r,n)().abort:(n(void 0,424),J)}function G(){}function V(t){t.iconsLoaderFlag||(t.iconsLoaderFlag=!0,setTimeout((()=>{t.iconsLoaderFlag=!1,function(t){t.pendingCallbacksFlag||(t.pendingCallbacksFlag=!0,setTimeout((()=>{t.pendingCallbacksFlag=!1;const e=t.loaderCallbacks?t.loaderCallbacks.slice(0):[];if(!e.length)return;let n=!1;const i=t.provider,r=t.prefix;e.forEach((e=>{const o=e.icons,s=o.pending.length;o.pending=o.pending.filter((e=>{if(e.prefix!==r)return!0;const s=e.name;if(t.icons[s])o.loaded.push({provider:i,prefix:r,name:s});else{if(!t.missing.has(s))return n=!0,!0;o.missing.push({provider:i,prefix:r,name:s})}return!1})),o.pending.length!==s&&(n||E([t],e.id),e.callback(o.loaded.slice(0),o.missing.slice(0),o.pending.slice(0),e.abort))}))})))}(t)})))}function K(t,e,n){function i(){const n=t.pendingIcons;e.forEach((e=>{n&&n.delete(e),t.icons[e]||t.missing.add(e)}))}if(n&&"object"==typeof n)try{if(!x(t,n).length)return void i()}catch(t){console.error(t)}i(),V(t)}function W(t,e){t instanceof Promise?t.then((t=>{e(t)})).catch((()=>{e(null)})):e(t)}function X(t,e){t.iconsToLoad?t.iconsToLoad=t.iconsToLoad.concat(e).sort():t.iconsToLoad=e,t.iconsQueueFlag||(t.iconsQueueFlag=!0,setTimeout((()=>{t.iconsQueueFlag=!1;const{provider:e,prefix:n}=t,i=t.iconsToLoad;if(delete t.iconsToLoad,!i||!i.length)return;const r=t.loadIcon;if(t.loadIcons&&(i.length>1||!r))return void W(t.loadIcons(i,n,e),(e=>{K(t,i,e)}));if(r)return void i.forEach((i=>{W(r(i,n,e),(e=>{K(t,[i],e?{prefix:n,icons:{[i]:e}}:null)}))}));const{valid:o,invalid:s}=function(t){const e=[],n=[];return t.forEach((t=>{(t.match(u)?e:n).push(t)})),{valid:e,invalid:n}}(i);if(s.length&&K(t,s,null),!o.length)return;const c=n.match(u)?S(e):null;if(!c)return void K(t,o,null);c.prepare(e,n,o).forEach((n=>{B(e,n,(e=>{K(t,n.icons,e)}))}))})))}const Y=(t,e)=>{const n=function(t,e=!0,n=!1){const i=[];return t.forEach((t=>{const r="string"==typeof t?l(t,e,n):t;r&&i.push(r)})),i}(t,!0,k()),i=function(t){const e={loaded:[],missing:[],pending:[]},n=Object.create(null);t.sort(((t,e)=>t.provider!==e.provider?t.provider.localeCompare(e.provider):t.prefix!==e.prefix?t.prefix.localeCompare(e.prefix):t.name.localeCompare(e.name)));let i={provider:"",prefix:"",name:""};return t.forEach((t=>{if(i.name===t.name&&i.prefix===t.prefix&&i.provider===t.provider)return;i=t;const r=t.provider,o=t.prefix,s=t.name,c=n[r]||(n[r]=Object.create(null)),a=c[o]||(c[o]=y(r,o));let u;u=s in a.icons?e.loaded:""===o||a.missing.has(s)?e.missing:e.pending;const l={provider:r,prefix:o,name:s};u.push(l)})),e}(n);if(!i.pending.length){let t=!0;return e&&setTimeout((()=>{t&&e(i.loaded,i.missing,i.pending,G)})),()=>{t=!1}}const r=Object.create(null),o=[];let s,c;return i.pending.forEach((t=>{const{provider:e,prefix:n}=t;if(n===c&&e===s)return;s=e,c=n,o.push(y(e,n));const i=r[e]||(r[e]=Object.create(null));i[n]||(i[n]=[])})),i.pending.forEach((t=>{const{provider:e,prefix:n,name:i}=t,o=y(e,n),s=o.pendingIcons||(o.pendingIcons=new Set);s.has(i)||(s.add(i),r[e][n].push(i))})),o.forEach((t=>{const e=r[t.provider][t.prefix];e.length&&X(t,e)})),e?function(t,e,n){const i=T++,r=E.bind(null,n,i);if(!e.pending.length)return r;const o={id:i,icons:e,callback:t,abort:r};return n.forEach((t=>{(t.loaderCallbacks||(t.loaderCallbacks=[])).push(o)})),r}(e,i,o):G},Z=t=>new Promise(((e,i)=>{const r="string"==typeof t?l(t,!0):t;r?Y([r||t],(o=>{if(o.length&&r){const t=A(r);if(t)return void e({...n,...t})}i(t)})):i(t)}));function tt(t){try{const e="string"==typeof t?JSON.parse(t):t;if("string"==typeof e.body)return{...e}}catch(t){}}let et=!1;try{et=0===navigator.vendor.indexOf("Apple")}catch(t){}const nt=/(-?[0-9.]*[0-9]+[0-9.]*)/g,it=/^-?[0-9.]*[0-9]+[0-9.]*$/g;function rt(t,e,n){if(1===e)return t;if(n=n||100,"number"==typeof t)return Math.ceil(t*e*n)/n;if("string"!=typeof t)return t;const i=t.split(nt);if(null===i||!i.length)return t;const r=[];let o=i.shift(),s=it.test(o);for(;;){if(s){const t=parseFloat(o);isNaN(t)?r.push(o):r.push(Math.ceil(t*e*n)/n)}else r.push(o);if(o=i.shift(),void 0===o)return r.join("");s=!s}}const ot=t=>"unset"===t||"undefined"===t||"none"===t;function st(t,e){const i={...n,...t},r={...o,...e},s={left:i.left,top:i.top,width:i.width,height:i.height};let c=i.body;[i,r].forEach((t=>{const e=[],n=t.hFlip,i=t.vFlip;let r,o=t.rotate;switch(n?i?o+=2:(e.push("translate("+(s.width+s.left).toString()+" "+(0-s.top).toString()+")"),e.push("scale(-1 1)"),s.top=s.left=0):i&&(e.push("translate("+(0-s.left).toString()+" "+(s.height+s.top).toString()+")"),e.push("scale(1 -1)"),s.top=s.left=0),o<0&&(o-=4*Math.floor(o/4)),o%=4,o){case 1:r=s.height/2+s.top,e.unshift("rotate(90 "+r.toString()+" "+r.toString()+")");break;case 2:e.unshift("rotate(180 "+(s.width/2+s.left).toString()+" "+(s.height/2+s.top).toString()+")");break;case 3:r=s.width/2+s.left,e.unshift("rotate(-90 "+r.toString()+" "+r.toString()+")")}o%2==1&&(s.left!==s.top&&(r=s.left,s.left=s.top,s.top=r),s.width!==s.height&&(r=s.width,s.width=s.height,s.height=r)),e.length&&(c=function(t,e,n){const i=function(t,e="defs"){let n="";const i=t.indexOf("<"+e);for(;i>=0;){const r=t.indexOf(">",i),o=t.indexOf("",o);if(-1===s)break;n+=t.slice(r+1,o).trim(),t=t.slice(0,i).trim()+t.slice(s+1)}return{defs:n,content:t}}(t);return r=i.defs,o=e+i.content+n,r?""+r+""+o:o;var r,o}(c,'',""))}));const a=r.width,u=r.height,l=s.width,f=s.height;let d,h;null===a?(h=null===u?"1em":"auto"===u?f:u,d=rt(h,l/f)):(d="auto"===a?l:a,h=null===u?rt(d,f/l):"auto"===u?f:u);const p={},g=(t,e)=>{ot(e)||(p[t]=e.toString())};g("width",d),g("height",h);const b=[s.left,s.top,l,f];return p.viewBox=b.join(" "),{attributes:p,viewBox:b,body:c}}function ct(t,e){let n=-1===t.indexOf("xlink:")?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(const t in e)n+=" "+t+'="'+e[t]+'"';return'"+t+""}function at(t){return'url("'+function(t){return"data:image/svg+xml,"+function(t){return t.replace(/"/g,"'").replace(/%/g,"%25").replace(/#/g,"%23").replace(//g,"%3E").replace(/\s+/g," ")}(t)}(t)+'")'}let ut=(()=>{let t;try{if(t=fetch,"function"==typeof t)return t}catch(t){}})();function lt(t){ut=t}function ft(){return ut}const dt={prepare:(t,e,n)=>{const i=[],r=function(t,e){const n=D(t);if(!n)return 0;let i;if(n.maxURL){let t=0;n.resources.forEach((e=>{const n=e;t=Math.max(t,n.length)}));const r=e+".json?icons=";i=n.maxURL-t-n.path.length-r.length}else i=0;return i}(t,e),o="icons";let s={type:o,provider:t,prefix:e,icons:[]},c=0;return n.forEach(((n,a)=>{c+=n.length+1,c>=r&&a>0&&(i.push(s),s={type:o,provider:t,prefix:e,icons:[]},c=n.length),s.icons.push(n)})),i.push(s),i},send:(t,e,n)=>{if(!ut)return void n("abort",424);let i=function(t){if("string"==typeof t){const e=D(t);if(e)return e.path}return"/"}(e.provider);switch(e.type){case"icons":{const t=e.prefix,n=e.icons.join(",");i+=t+".json?"+new URLSearchParams({icons:n}).toString();break}case"custom":{const t=e.uri;i+="/"===t.slice(0,1)?t.slice(1):t;break}default:return void n("abort",400)}let r=503;ut(t+i).then((t=>{const e=t.status;if(200===e)return r=501,t.json();setTimeout((()=>{n(function(t){return 404===t}(e)?"abort":"next",e)}))})).then((t=>{"object"==typeof t&&null!==t?setTimeout((()=>{n("success",t)})):setTimeout((()=>{404===t?n("abort",t):n("next",r)}))})).catch((()=>{n("next",r)}))}};function ht(t,e,n){y(n||"",e).loadIcons=t}function pt(t,e,n){y(n||"",e).loadIcon=t}const gt="data-style";let bt="";function vt(t){bt=t}function mt(t,e){let n=Array.from(t.childNodes).find((t=>t.hasAttribute&&t.hasAttribute(gt)));n||(n=document.createElement("style"),n.setAttribute(gt,gt),t.appendChild(n)),n.textContent=":host{display:inline-block;vertical-align:"+(e?"-0.125em":"0")+"}span,svg{display:block;margin:auto}"+bt}const yt={"background-color":"currentColor"},xt={"background-color":"transparent"},_t={image:"var(--svg)",repeat:"no-repeat",size:"100% 100%"},wt={"-webkit-mask":yt,mask:yt,background:xt};for(const t in wt){const e=wt[t];for(const n in _t)e[t+"-"+n]=_t[n]}function kt(t){return t?t+(t.match(/^[-0-9.]+$/)?"px":""):"inherit"}let At;function jt(t){return void 0===At&&function(){try{At=window.trustedTypes.createPolicy("iconify",{createHTML:t=>t})}catch(t){At=null}}(),At?At.createHTML(t):t}function Ot(t){return Array.from(t.childNodes).find((t=>{const e=t.tagName&&t.tagName.toUpperCase();return"SPAN"===e||"SVG"===e}))}function Ct(t,e){const i=e.icon.data,r=e.customisations,o=st(i,r);r.preserveAspectRatio&&(o.attributes.preserveAspectRatio=r.preserveAspectRatio);const s=e.renderedMode;let c;if("svg"===s)c=function(t){const e=document.createElement("span"),n=t.attributes;let i="";n.width||(i="width: inherit;"),n.height||(i+="height: inherit;"),i&&(n.style=i);const r=ct(t.body,n);return e.innerHTML=jt(r),e.firstChild}(o);else c=function(t,e,n){const i=document.createElement("span");let r=t.body;-1!==r.indexOf("{this._check()})))}_check(){if(!this._checkQueued)return;this._checkQueued=!1;const t=this._state,e=this.getAttribute("icon");if(e!==t.icon.value)return void this._iconChanged(e);if(!t.rendered||!this._visible)return;const n=this.getAttribute("mode"),i=a(this);t.attrMode===n&&!function(t,e){for(const n in c)if(t[n]!==e[n])return!0;return!1}(t.customisations,i)&&Ot(this._shadowRoot)||this._renderIcon(t.icon,i,n)}_iconChanged(t){const e=function(t,e){if("object"==typeof t)return{data:tt(t),value:t};if("string"!=typeof t)return{value:t};if(t.includes("{")){const e=tt(t);if(e)return{data:e,value:t}}const n=l(t,!0,!0);if(!n)return{value:t};const i=A(n);if(void 0!==i||!n.prefix)return{value:t,name:n,data:i};const r=Y([n],(()=>e(t,n,A(n))));return{value:t,name:n,loading:r}}(t,((t,e,n)=>{const i=this._state;if(i.rendered||this.getAttribute("icon")!==t)return;const r={value:t,name:e,data:n};r.data?this._gotIconData(r):i.icon=r}));e.data?this._gotIconData(e):this._state=It(e,this._state.inline,this._state)}_forceRender(){if(this._visible)this._queueCheck();else{const t=Ot(this._shadowRoot);t&&this._shadowRoot.removeChild(t)}}_gotIconData(t){this._checkQueued=!1,this._renderIcon(t,a(this),this.getAttribute("mode"))}_renderIcon(t,e,n){const i=function(t,e){switch(e){case"svg":case"bg":case"mask":return e}return"style"===e||!et&&-1!==t.indexOf("{const e=t.some((t=>t.isIntersecting));e!==this._visible&&(this._visible=e,this._forceRender())})),this._observer.observe(this)}catch(t){if(this._observer){try{this._observer.disconnect()}catch(t){}this._observer=null}}}stopObserver(){this._observer&&(this._observer.disconnect(),this._observer=null,this._visible=!0,this._connected&&this._forceRender())}};r.forEach((t=>{t in o.prototype||Object.defineProperty(o.prototype,t,{get:function(){return this.getAttribute(t)},set:function(e){null!==e?this.setAttribute(t,e):this.removeAttribute(t)}})}));const s=function(){let t;R("",dt),k(!0);try{t=window}catch(t){}if(t){if(void 0!==t.IconifyPreload){const e=t.IconifyPreload,n="Invalid IconifyPreload syntax.";"object"==typeof e&&null!==e&&(e instanceof Array?e:[e]).forEach((t=>{try{("object"!=typeof t||null===t||t instanceof Array||"object"!=typeof t.icons||"string"!=typeof t.prefix||!O(t))&&console.error(n)}catch(t){console.error(n)}}))}if(void 0!==t.IconifyProviders){const e=t.IconifyProviders;if("object"==typeof e&&null!==e)for(const t in e){const n="IconifyProviders["+t+"] is invalid.";try{const i=e[t];if("object"!=typeof i||!i||void 0===i.resources)continue;U(t,i)||console.error(n)}catch(t){console.error(n)}}}}return{enableCache:t=>{},disableCache:t=>{},iconLoaded:C,iconExists:C,getIcon:I,listIcons:_,addIcon:j,addCollection:O,calculateSize:rt,buildIcon:st,iconToHTML:ct,svgToURL:at,loadIcons:Y,loadIcon:Z,addAPIProvider:U,setCustomIconLoader:pt,setCustomIconsLoader:ht,appendCustomStyle:vt,_api:{getAPIConfig:D,setAPIModule:R,sendAPIQuery:B,setFetch:lt,getFetch:ft,listAPIProviders:H}}}();for(const t in s)o[t]=o.prototype[t]=s[t];e.define(t,o)}()}(); diff --git a/web_iconify/static/src/css/web_iconify.css b/web_iconify/static/src/css/web_iconify.css new file mode 100644 index 00000000000..265f70dfbae --- /dev/null +++ b/web_iconify/static/src/css/web_iconify.css @@ -0,0 +1,5 @@ +iconify-icon { + display: inline-block; + width: 1em; + height: 1em; +} diff --git a/web_iconify_proxy/README.rst b/web_iconify_proxy/README.rst new file mode 100644 index 00000000000..474f20a617b --- /dev/null +++ b/web_iconify_proxy/README.rst @@ -0,0 +1,94 @@ +================= +Web Iconify Proxy +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:540c039653c052dd03103f8763a28c639f23b6e3a74191b78a2dd984352b5127 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_iconify_proxy + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_iconify_proxy + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo's +ir.attachment model. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally. No specific usage +instructions are required. + +Changelog +========= + +18.0.1.0.0 (2025-02-23) +----------------------- + +- Initial version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* jaco.tech + +Contributors +------------ + +- Jaco Waes + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_iconify_proxy/__init__.py b/web_iconify_proxy/__init__.py new file mode 100644 index 00000000000..e046e49fbe2 --- /dev/null +++ b/web_iconify_proxy/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/web_iconify_proxy/__manifest__.py b/web_iconify_proxy/__manifest__.py new file mode 100644 index 00000000000..bb1656233db --- /dev/null +++ b/web_iconify_proxy/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 jaco.tech +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web Iconify Proxy", + "summary": "Proxies requests to the Iconify API, providing SVG icons, CSS," + " and JSON data. It also implements caching using Odoo's ir.attachment " + "model.", + "version": "18.0.1.0.0", + "category": "Website", + "website": "https://github.com/OCA/web", + "author": "jaco.tech, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["web"], + "data": [], + "assets": { + "web.assets_backend": [ + "web_iconify_proxy/static/src/js/iconify_api_provider.js", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/web_iconify_proxy/controllers/__init__.py b/web_iconify_proxy/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/web_iconify_proxy/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/web_iconify_proxy/controllers/main.py b/web_iconify_proxy/controllers/main.py new file mode 100644 index 00000000000..a75a48119f0 --- /dev/null +++ b/web_iconify_proxy/controllers/main.py @@ -0,0 +1,213 @@ +import base64 +import datetime +import logging +import re + +import requests + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class IconifyProxyController(http.Controller): + """Controller for proxying Iconify requests.""" + + def _fetch_iconify_data( + self, upstream_url, content_type, prefix, icons=None, icon=None + ): + """Fetches data from the Iconify API or the local cache. + + Args: + upstream_url (str): The URL of the Iconify API endpoint. + content_type (str): The expected content type of the response. + prefix (str): The icon prefix. + icons (str, optional): Comma-separated list of icons (for CSS and JSON). + icon (str, optional): The icon name (for SVG). + + Returns: + Response: The HTTP response. + """ + + # Validate prefix + if not re.match(r"^[a-z0-9-]+$", prefix): + return request.not_found() + + # Validate icon (if provided) + if icon and not re.match(r"^[a-z0-9:-]+$", icon): + return request.not_found() + + # Validate icons (if provided) + if icons: + icon_list = icons.split(",") + for single_icon in icon_list: + if not re.match(r"^[a-z0-9:-]+$", single_icon): + return request.not_found() + icons = ",".join(icon_list) # Reconstruct to prevent injection + + Attachment = request.env["ir.attachment"].sudo() + if content_type == "image/svg+xml": + name = f"{prefix}-{icon}" + res_model = "iconify.svg" + elif content_type == "text/css": + name = f"{prefix}-{icons}" + res_model = "iconify.css" + elif content_type == "application/json": + name = f"{prefix}-{icons}" + res_model = "iconify.json" + else: + return request.not_found() + + attachment = Attachment.search( + [("res_model", "=", res_model), ("name", "=", name)], limit=1 + ) + + if attachment: + _logger.info(f"Serving from cache: {name}") + data = base64.b64decode(attachment.datas) + headers = [ + ("Content-Type", content_type), + ("Cache-Control", "public, max-age=31536000"), + ("X-Cached-At", str(attachment.create_date)), + ] + return request.make_response(data, headers) + + _logger.info(f"Fetching from API: {upstream_url}") + try: + response = requests.get(upstream_url, timeout=5) + response.raise_for_status() # Raise HTTPError for bad responses + except requests.exceptions.RequestException as e: + _logger.error(f"Request to Iconify API failed: {e}") + return request.not_found() + + data = response.content + attachment = Attachment.create( + { + "name": name, + "datas": base64.b64encode(data).decode("utf-8"), + "res_model": res_model, + "res_id": 0, + "type": "binary", + } + ) + + headers = [ + ("Content-Type", content_type), + ("Cache-Control", "public, max-age=31536000"), # Cache for one year + ] + return request.make_response(data, headers) + + @http.route( + "/web_iconify_proxy//.svg", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_svg(self, prefix, icon, **params): + """Gets an SVG icon from the Iconify API. + + Args: + prefix (str): The icon prefix. + icon (str): The icon name. + + Returns: + Response: The HTTP response containing the SVG data. + """ + upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg" + return self._fetch_iconify_data( + upstream_url, "image/svg+xml", prefix, icon=icon + ) + + @http.route( + "/web_iconify_proxy/.css", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_css(self, prefix, **params): + """Gets CSS for a set of icons from the Iconify API. + + Args: + prefix (str): The icon prefix. + params (dict): Query parameters, including 'icons'. + + Returns: + Response: The HTTP response containing the CSS data. + """ + icons = params.get("icons") + if not icons: + return request.not_found() + upstream_url = f"https://api.iconify.design/{prefix}.css?icons={icons}" + return self._fetch_iconify_data(upstream_url, "text/css", prefix, icons=icons) + + @http.route( + "/web_iconify_proxy/.json", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_json(self, prefix, **params): + """Gets JSON data for a set of icons from the Iconify API. + + Args: + prefix (str): The icon prefix. + params (dict): Query parameters, including 'icons'. + + Returns: + Response: The HTTP response containing the JSON data. + """ + icons = params.get("icons") + if not icons: + return request.not_found() + upstream_url = f"https://api.iconify.design/{prefix}.json?icons={icons}" + return self._fetch_iconify_data( + upstream_url, "application/json", prefix, icons=icons + ) + + @http.route( + "/web_iconify_proxy/last-modified", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_last_modified(self, **params): + """Gets the last modification timestamp for the cached data. + + Args: + params (dict): Query parameters, including 'prefixes'. + + Returns: + Response: The HTTP response containing the timestamp. + """ + prefixes = params.get("prefixes") + if not prefixes: + return request.not_found() + + prefixes_list = prefixes.split(",") + + Attachment = request.env["ir.attachment"].sudo() + + # Search for attachments related to iconify + attachments = Attachment.search( + [ + ("res_model", "in", ["iconify.svg", "iconify.css", "iconify.json"]), + # Check if name contains any of the prefixes + ("name", "like", "|".join(prefixes_list)), + ] + ) + + if not attachments: + return request.not_found() + + # Find the latest create_date + latest_timestamp = max( + attachments.mapped("create_date"), default=datetime.datetime.min + ) + + headers = [("Content-Type", "application/json"), ("Cache-Control", "no-cache")] + return request.make_response(str(latest_timestamp.timestamp()), headers) diff --git a/web_iconify_proxy/pyproject.toml b/web_iconify_proxy/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_iconify_proxy/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_iconify_proxy/readme/CONTRIBUTORS.md b/web_iconify_proxy/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..2541b203029 --- /dev/null +++ b/web_iconify_proxy/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jaco Waes \ diff --git a/web_iconify_proxy/readme/DESCRIPTION.md b/web_iconify_proxy/readme/DESCRIPTION.md new file mode 100644 index 00000000000..e9134e370c9 --- /dev/null +++ b/web_iconify_proxy/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo's +ir.attachment model. diff --git a/web_iconify_proxy/readme/HISTORY.md b/web_iconify_proxy/readme/HISTORY.md new file mode 100644 index 00000000000..90f6389743d --- /dev/null +++ b/web_iconify_proxy/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.1.0.0 (2025-02-23) + +- Initial version. diff --git a/web_iconify_proxy/readme/USAGE.md b/web_iconify_proxy/readme/USAGE.md new file mode 100644 index 00000000000..4387a110056 --- /dev/null +++ b/web_iconify_proxy/readme/USAGE.md @@ -0,0 +1,3 @@ +This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally. No specific usage +instructions are required. diff --git a/web_iconify_proxy/static/description/index.html b/web_iconify_proxy/static/description/index.html new file mode 100644 index 00000000000..0473244d4c0 --- /dev/null +++ b/web_iconify_proxy/static/description/index.html @@ -0,0 +1,446 @@ + + + + + +Web Iconify Proxy + + + +
+

Web Iconify Proxy

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo’s +ir.attachment model.

+

Table of contents

+ +
+

Usage

+

This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally. No specific usage +instructions are required.

+
+
+

Changelog

+
+

18.0.1.0.0 (2025-02-23)

+
    +
  • Initial version.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • jaco.tech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_iconify_proxy/static/src/js/iconify_api_provider.js b/web_iconify_proxy/static/src/js/iconify_api_provider.js new file mode 100644 index 00000000000..34e1c1993f5 --- /dev/null +++ b/web_iconify_proxy/static/src/js/iconify_api_provider.js @@ -0,0 +1,8 @@ +/** @odoo-module ignore **/ + +// eslint-disable-next-line no-undef +const IconifyIcon = window.customElements.get("iconify-icon"); + +IconifyIcon.addAPIProvider("", { + resources: ["/web_iconify_proxy"], +}); diff --git a/web_iconify_proxy/tests/__init__.py b/web_iconify_proxy/tests/__init__.py new file mode 100644 index 00000000000..6c9812dc2f7 --- /dev/null +++ b/web_iconify_proxy/tests/__init__.py @@ -0,0 +1 @@ +from . import test_main diff --git a/web_iconify_proxy/tests/test_main.py b/web_iconify_proxy/tests/test_main.py new file mode 100644 index 00000000000..9f1fdb3f705 --- /dev/null +++ b/web_iconify_proxy/tests/test_main.py @@ -0,0 +1,97 @@ +from unittest.mock import patch + +import requests + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged("post_install", "-at_install") +class TestIconifyProxyController(HttpCase): + def test_get_svg_success(self): + response = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "image/svg+xml") + # Add basic check for SVG content (can be improved) + self.assertTrue(b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + # First request, should fetch from API + response1 = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response1.status_code, 200) + self.assertFalse("X-Cached-At" in response1.headers) # Check that is NOT cached + + # Second request, should be served from cache + response2 = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response2.status_code, 200) + self.assertTrue("X-Cached-At" in response2.headers) # Check that IS cached + + # Check that content is the same + self.assertEqual(response1.content, response2.content) + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_api_error(self, mock_get): + # Mock requests.get to simulate an API error + mock_get.side_effect = requests.exceptions.RequestException("Simulated Error") + response = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response.status_code, 404) From be5d3f3c26805ba01b2e02a734549669f3e3e154 Mon Sep 17 00:00:00 2001 From: jwaes Date: Sun, 23 Feb 2025 21:42:57 +0000 Subject: [PATCH 2/4] squashing syntax fixes and using assertLog in unittest --- web_favicon/__manifest__.py | 5 +- web_iconify_proxy/README.rst | 6 +-- web_iconify_proxy/__manifest__.py | 7 +-- web_iconify_proxy/controllers/main.py | 18 +++---- .../static/description/index.html | 2 +- web_iconify_proxy/tests/test_main.py | 52 +++++++++++++++++-- web_no_bubble/__manifest__.py | 2 +- web_notify/README.rst | 32 ++++++------ web_notify/__manifest__.py | 2 +- 9 files changed, 83 insertions(+), 43 deletions(-) diff --git a/web_favicon/__manifest__.py b/web_favicon/__manifest__.py index 4f55e4a7af8..3dce9e1ec5d 100644 --- a/web_favicon/__manifest__.py +++ b/web_favicon/__manifest__.py @@ -6,10 +6,7 @@ { "name": "Custom shortcut icon", "version": "18.0.1.0.0", - "author": "Therp BV, " - "Tecnativa, " - "OERP Canada," - "Odoo Community Association (OCA)", + "author": "Therp BV, Tecnativa, OERP Canada,Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Website", "summary": "Allows to set a custom shortcut icon (aka favicon)", diff --git a/web_iconify_proxy/README.rst b/web_iconify_proxy/README.rst index 474f20a617b..738966d83bf 100644 --- a/web_iconify_proxy/README.rst +++ b/web_iconify_proxy/README.rst @@ -13,9 +13,9 @@ Web Iconify Proxy .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github :target: https://github.com/OCA/web/tree/18.0/web_iconify_proxy :alt: OCA/web diff --git a/web_iconify_proxy/__manifest__.py b/web_iconify_proxy/__manifest__.py index bb1656233db..4f5fe5ad5a0 100644 --- a/web_iconify_proxy/__manifest__.py +++ b/web_iconify_proxy/__manifest__.py @@ -4,13 +4,13 @@ { "name": "Web Iconify Proxy", "summary": "Proxies requests to the Iconify API, providing SVG icons, CSS," - " and JSON data. It also implements caching using Odoo's ir.attachment " - "model.", + " and JSON data. It also implements caching using Odoo's " + "ir.attachment model.", "version": "18.0.1.0.0", "category": "Website", "website": "https://github.com/OCA/web", "author": "jaco.tech, Odoo Community Association (OCA)", - "license": "AGPL-3", + "license": "LGPL-3", "depends": ["web"], "data": [], "assets": { @@ -20,4 +20,5 @@ }, "installable": True, "auto_install": False, + "tests": ["tests/test_main.py"], } diff --git a/web_iconify_proxy/controllers/main.py b/web_iconify_proxy/controllers/main.py index a75a48119f0..696fd01cec1 100644 --- a/web_iconify_proxy/controllers/main.py +++ b/web_iconify_proxy/controllers/main.py @@ -32,18 +32,18 @@ def _fetch_iconify_data( # Validate prefix if not re.match(r"^[a-z0-9-]+$", prefix): - return request.not_found() + raise request.not_found() # Validate icon (if provided) if icon and not re.match(r"^[a-z0-9:-]+$", icon): - return request.not_found() + raise request.not_found() # Validate icons (if provided) if icons: icon_list = icons.split(",") for single_icon in icon_list: if not re.match(r"^[a-z0-9:-]+$", single_icon): - return request.not_found() + raise request.not_found() icons = ",".join(icon_list) # Reconstruct to prevent injection Attachment = request.env["ir.attachment"].sudo() @@ -57,7 +57,7 @@ def _fetch_iconify_data( name = f"{prefix}-{icons}" res_model = "iconify.json" else: - return request.not_found() + raise request.not_found() attachment = Attachment.search( [("res_model", "=", res_model), ("name", "=", name)], limit=1 @@ -79,7 +79,7 @@ def _fetch_iconify_data( response.raise_for_status() # Raise HTTPError for bad responses except requests.exceptions.RequestException as e: _logger.error(f"Request to Iconify API failed: {e}") - return request.not_found() + raise request.not_found() from e data = response.content attachment = Attachment.create( @@ -139,7 +139,7 @@ def get_css(self, prefix, **params): """ icons = params.get("icons") if not icons: - return request.not_found() + raise request.not_found() upstream_url = f"https://api.iconify.design/{prefix}.css?icons={icons}" return self._fetch_iconify_data(upstream_url, "text/css", prefix, icons=icons) @@ -162,7 +162,7 @@ def get_json(self, prefix, **params): """ icons = params.get("icons") if not icons: - return request.not_found() + raise request.not_found() upstream_url = f"https://api.iconify.design/{prefix}.json?icons={icons}" return self._fetch_iconify_data( upstream_url, "application/json", prefix, icons=icons @@ -186,7 +186,7 @@ def get_last_modified(self, **params): """ prefixes = params.get("prefixes") if not prefixes: - return request.not_found() + raise request.not_found() prefixes_list = prefixes.split(",") @@ -202,7 +202,7 @@ def get_last_modified(self, **params): ) if not attachments: - return request.not_found() + raise request.not_found() # Find the latest create_date latest_timestamp = max( diff --git a/web_iconify_proxy/static/description/index.html b/web_iconify_proxy/static/description/index.html index 0473244d4c0..e8dc1c60ccc 100644 --- a/web_iconify_proxy/static/description/index.html +++ b/web_iconify_proxy/static/description/index.html @@ -369,7 +369,7 @@

Web Iconify Proxy

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:540c039653c052dd03103f8763a28c639f23b6e3a74191b78a2dd984352b5127 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

This module acts as a proxy for the Iconify API, allowing Odoo to fetch icons through the Odoo server rather than directly from the Iconify API. This improves performance by caching the icons locally using Odoo’s diff --git a/web_iconify_proxy/tests/test_main.py b/web_iconify_proxy/tests/test_main.py index 9f1fdb3f705..837f6c7017d 100644 --- a/web_iconify_proxy/tests/test_main.py +++ b/web_iconify_proxy/tests/test_main.py @@ -1,3 +1,4 @@ +import base64 from unittest.mock import patch import requests @@ -8,21 +9,42 @@ @tagged("post_install", "-at_install") class TestIconifyProxyController(HttpCase): - def test_get_svg_success(self): + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_get_svg_success(self, mock_get): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + response = self.url_open("/web_iconify_proxy/mdi/home.svg") self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], "image/svg+xml") # Add basic check for SVG content (can be improved) self.assertTrue(b" -- Serpent Consulting Services Pvt. Ltd. -- Aitor Bouzas -- Shepilov Vladislav -- Kevin Khao -- `Tecnativa `__: +- Laurent Mignon +- Serpent Consulting Services Pvt. Ltd. +- Aitor Bouzas +- Shepilov Vladislav +- Kevin Khao +- `Tecnativa `__: - - David Vidal + - David Vidal -- Nikul Chaudhary -- Tris Doan +- Nikul Chaudhary +- Tris Doan Other credits ------------- diff --git a/web_notify/__manifest__.py b/web_notify/__manifest__.py index 47c7084383c..f8c2aebdc38 100644 --- a/web_notify/__manifest__.py +++ b/web_notify/__manifest__.py @@ -8,7 +8,7 @@ Send notification messages to user""", "version": "18.0.1.0.1", "license": "AGPL-3", - "author": "ACSONE SA/NV," "AdaptiveCity," "Odoo Community Association (OCA)", + "author": "ACSONE SA/NV,AdaptiveCity,Odoo Community Association (OCA)", "development_status": "Production/Stable", "website": "https://github.com/OCA/web", "depends": ["web", "bus", "base", "mail"], From 12e11b00c6becb9f80e446832a247016ac80f740 Mon Sep 17 00:00:00 2001 From: jwaes Date: Mon, 24 Feb 2025 19:36:41 +0000 Subject: [PATCH 3/4] Adding extra functionality for svg (rotate, color, ... ) --- web_iconify_proxy/README.rst | 30 +++- web_iconify_proxy/controllers/main.py | 73 ++++++++-- web_iconify_proxy/readme/USAGE.md | 22 ++- .../static/description/index.html | 59 +++++--- web_iconify_proxy/tests/test_main.py | 137 +++++++++++++++++- 5 files changed, 289 insertions(+), 32 deletions(-) diff --git a/web_iconify_proxy/README.rst b/web_iconify_proxy/README.rst index 738966d83bf..33658c42961 100644 --- a/web_iconify_proxy/README.rst +++ b/web_iconify_proxy/README.rst @@ -42,8 +42,34 @@ Usage ===== This module works in conjunction with web_iconify. Once installed, icons -will be served through the proxy and cached locally. No specific usage -instructions are required. +will be served through the proxy and cached locally. + +SVG Icon Parameters +------------------- + +You can customize SVG icons by adding query parameters to the URL. The +format is: + +``/web_iconify_proxy//.svg?param1=value1¶m2=value2...`` + +Available parameters: + +- **color**: Icon color (e.g., ``color=red``, ``color=%23ff0000``). +- **width**: Icon width (e.g., ``width=50``, ``width=50px``). +- **height**: Icon height (e.g., ``height=50``, ``height=50px``). If + only one dimension is specified, the other will be calculated + automatically to maintain aspect ratio. +- **flip**: Flip the icon. Possible values: ``horizontal``, + ``vertical``, or both (e.g., ``flip=horizontal``, ``flip=vertical``, + ``flip=horizontal,vertical``). +- **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., + ``rotate=90``, ``rotate=180``). +- **box**: Set to ``true`` to add an empty rectangle to the SVG that + matches the viewBox (e.g., ``box=true``). + +Example: + +``/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal`` Changelog ========= diff --git a/web_iconify_proxy/controllers/main.py b/web_iconify_proxy/controllers/main.py index 696fd01cec1..b7c9ad934df 100644 --- a/web_iconify_proxy/controllers/main.py +++ b/web_iconify_proxy/controllers/main.py @@ -1,3 +1,4 @@ +import ast import base64 import datetime import logging @@ -15,7 +16,13 @@ class IconifyProxyController(http.Controller): """Controller for proxying Iconify requests.""" def _fetch_iconify_data( - self, upstream_url, content_type, prefix, icons=None, icon=None + self, + upstream_url, + content_type, + prefix, + icons=None, + icon=None, + normalized_params_string="", ): """Fetches data from the Iconify API or the local cache. @@ -25,22 +32,28 @@ def _fetch_iconify_data( prefix (str): The icon prefix. icons (str, optional): Comma-separated list of icons (for CSS and JSON). icon (str, optional): The icon name (for SVG). + normalized_params_string (str, optional): Normalized parameters string. Returns: Response: The HTTP response. """ + # Validate prefix + prefix = prefix.lower() + # Validate prefix if not re.match(r"^[a-z0-9-]+$", prefix): raise request.not_found() # Validate icon (if provided) - if icon and not re.match(r"^[a-z0-9:-]+$", icon): - raise request.not_found() + if icon: + icon = icon.lower() + if not re.match(r"^[a-z0-9:-]+$", icon): + raise request.not_found() # Validate icons (if provided) if icons: - icon_list = icons.split(",") + icon_list = [i.lower() for i in icons.split(",")] for single_icon in icon_list: if not re.match(r"^[a-z0-9:-]+$", single_icon): raise request.not_found() @@ -48,13 +61,13 @@ def _fetch_iconify_data( Attachment = request.env["ir.attachment"].sudo() if content_type == "image/svg+xml": - name = f"{prefix}-{icon}" + name = f"{prefix}-{icon}-{normalized_params_string.lower()}" res_model = "iconify.svg" elif content_type == "text/css": - name = f"{prefix}-{icons}" + name = f"{prefix}-{icons}-{normalized_params_string.lower()}" res_model = "iconify.css" elif content_type == "application/json": - name = f"{prefix}-{icons}" + name = f"{prefix}-{icons}-{normalized_params_string.lower()}" res_model = "iconify.json" else: raise request.not_found() @@ -98,6 +111,36 @@ def _fetch_iconify_data( ] return request.make_response(data, headers) + def _normalize_params_common(self, params): + """Normalizes common parameters for Iconify requests.""" + normalized = [] + for key in sorted(params.keys()): # Sort keys alphabetically + normalized.append(f"{key.lower()}={params[key]}") + return ";".join(normalized) + + def _normalize_params_svg(self, params): + """Normalizes parameters specifically for SVG requests.""" + allowed_params = ["color", "width", "height", "flip", "rotate", "box"] + normalized = [] + for key in sorted(params.keys()): + if key in allowed_params: + value = params[key] + key = key.lower() + # Basic type validation + if key in ("width", "height", "rotate") and not ( + isinstance(value, str) or isinstance(value, int) + ): + continue # Skip invalid values + if key == "box": + try: + value = ast.literal_eval(str(value).lower()) + if not isinstance(value, bool): + continue + except (ValueError, SyntaxError): + continue + normalized.append(f"{key}={value}") + return ";".join(normalized) + @http.route( "/web_iconify_proxy//.svg", type="http", @@ -115,9 +158,21 @@ def get_svg(self, prefix, icon, **params): Returns: Response: The HTTP response containing the SVG data. """ - upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg" + normalized_params = self._normalize_params_svg(params) + if normalized_params: + query_string = normalized_params.replace(";", "&") + upstream_url = ( + f"https://api.iconify.design/{prefix}/{icon}.svg?{query_string}" + ) + else: + upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg" + return self._fetch_iconify_data( - upstream_url, "image/svg+xml", prefix, icon=icon + upstream_url, + "image/svg+xml", + prefix, + icon=icon, + normalized_params_string=normalized_params, ) @http.route( diff --git a/web_iconify_proxy/readme/USAGE.md b/web_iconify_proxy/readme/USAGE.md index 4387a110056..ff4dbaf9d05 100644 --- a/web_iconify_proxy/readme/USAGE.md +++ b/web_iconify_proxy/readme/USAGE.md @@ -1,3 +1,21 @@ This module works in conjunction with web_iconify. Once installed, icons -will be served through the proxy and cached locally. No specific usage -instructions are required. +will be served through the proxy and cached locally. + +## SVG Icon Parameters + +You can customize SVG icons by adding query parameters to the URL. The format is: + +`/web_iconify_proxy//.svg?param1=value1¶m2=value2...` + +Available parameters: + +* **color**: Icon color (e.g., `color=red`, `color=%23ff0000`). +* **width**: Icon width (e.g., `width=50`, `width=50px`). +* **height**: Icon height (e.g., `height=50`, `height=50px`). If only one dimension is specified, the other will be calculated automatically to maintain aspect ratio. +* **flip**: Flip the icon. Possible values: `horizontal`, `vertical`, or both (e.g., `flip=horizontal`, `flip=vertical`, `flip=horizontal,vertical`). +* **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., `rotate=90`, `rotate=180`). +* **box**: Set to `true` to add an empty rectangle to the SVG that matches the viewBox (e.g., `box=true`). + +Example: + +`/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal` diff --git a/web_iconify_proxy/static/description/index.html b/web_iconify_proxy/static/description/index.html index e8dc1c60ccc..020a34cc47f 100644 --- a/web_iconify_proxy/static/description/index.html +++ b/web_iconify_proxy/static/description/index.html @@ -377,16 +377,19 @@

Web Iconify Proxy

Table of contents