From b071baaae993a81fcf368c4eb13280282a4fb887 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Thu, 14 Sep 2023 12:03:50 -0600 Subject: [PATCH] feat: Add support for Feather Icons (#959) --- README.md | 37 ++++++++++++ package.json | 4 +- server/controllers/controller.ts | 29 ++++++--- server/services/fetchBadges.ts | 8 +-- server/services/iconDatabase.ts | 51 ---------------- server/services/icons/FeatherIconsService.ts | 24 ++++++++ server/services/icons/IconsService.ts | 15 +++++ server/services/icons/OcticonsService.ts | 24 ++++++++ server/services/icons/iconDatabase.ts | 59 +++++++++++++++++++ .../{setLogoColor.ts => logoColor.ts} | 8 +-- server/services/octicons.ts | 27 --------- yarn.lock | 23 ++++++++ 12 files changed, 211 insertions(+), 98 deletions(-) delete mode 100644 server/services/iconDatabase.ts create mode 100644 server/services/icons/FeatherIconsService.ts create mode 100644 server/services/icons/IconsService.ts create mode 100644 server/services/icons/OcticonsService.ts create mode 100644 server/services/icons/iconDatabase.ts rename server/services/{setLogoColor.ts => logoColor.ts} (91%) delete mode 100644 server/services/octicons.ts diff --git a/README.md b/README.md index ebc8a07e..eee0213d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,43 @@ All [250+ Octicons](https://primer.style/octicons/) from GitHub are supported by [heart]: https://custom-icon-badges.demolab.com/badge/Heart-D15E9B.svg?logo=heart [mail]: https://custom-icon-badges.demolab.com/badge/Mail-E61B23.svg?logo=mail +### Feather Icons + +All [250+ Feather Icons](https://feathericons.com/) are supported by Custom Icon Badges. + +**Note:** To use Feather Icons, you must use add the query parameter `logoSource=feather` to the URL in addition to the `logo` parameter. + +| Slug | Example | +| ------------------ | --------------------------------------------- | +| `activity` | [![activity][activity]][activity] | +| `airplay` | [![airplay][airplay]][airplay] | +| `bell` | [![bell][bell]][bell] | +| `bluetooth` | [![bluetooth][bluetooth]][bluetooth] | +| `box` | [![box][box]][box] | +| `calendar` | [![calendar][calendar]][calendar] | +| `cast` | [![cast][cast]][cast] | +| `command` | [![command][command]][command] | +| `lock` | [![lock][lock]][lock] | +| `unlock` | [![unlock][unlock]][unlock] | +| `upload-cloud` | [![upload-cloud][upload-cloud]][upload-cloud] | +| `tv` | [![tv][tv]][tv] | +| `youtube` | [![youtube][youtube]][youtube] | +| More Feather Icons | [View all ⇨](https://feathericons.com/) | + +[activity]: https://custom-icon-badges.demolab.com/badge/Activity-red.svg?logo=activity&logoSource=feather&logoColor=white +[airplay]: https://custom-icon-badges.demolab.com/badge/Airplay-orange.svg?logo=airplay&logoSource=feather&logoColor=white +[bell]: https://custom-icon-badges.demolab.com/badge/Bell-yellow.svg?logo=bell&logoSource=feather&logoColor=white +[bluetooth]: https://custom-icon-badges.demolab.com/badge/Bluetooth-green.svg?logo=bluetooth&logoSource=feather&logoColor=white +[box]: https://custom-icon-badges.demolab.com/badge/Box-blue.svg?logo=box&logoSource=feather&logoColor=white +[calendar]: https://custom-icon-badges.demolab.com/badge/Calendar-purple.svg?logo=calendar&logoSource=feather&logoColor=white +[cast]: https://custom-icon-badges.demolab.com/badge/Cast-pink.svg?logo=cast&logoSource=feather&logoColor=white +[command]: https://custom-icon-badges.demolab.com/badge/Command-brown.svg?logo=command&logoSource=feather&logoColor=white +[lock]: https://custom-icon-badges.demolab.com/badge/Lock-grey.svg?logo=lock&logoSource=feather&logoColor=white +[unlock]: https://custom-icon-badges.demolab.com/badge/Unlock-black.svg?logo=unlock&logoSource=feather&logoColor=white +[upload-cloud]: https://custom-icon-badges.demolab.com/badge/Upload%20Cloud-purple.svg?logo=upload-cloud&logoSource=feather&logoColor=white +[tv]: https://custom-icon-badges.demolab.com/badge/TV-blue.svg?logo=tv&logoSource=feather&logoColor=white +[youtube]: https://custom-icon-badges.demolab.com/badge/YouTube-red.svg?logo=youtube&logoSource=feather&logoColor=white + ### Miscellaneous | | | | | diff --git a/package.json b/package.json index d6ebec33..15b09225 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.0.0", + "feather-icons": "^4.29.1", "monk": "^7.3.4", "node-fetch": "^3.3.2", "qs": "^6.11.2", @@ -22,6 +23,7 @@ "@babel/core": "^7.22.17", "@babel/eslint-parser": "^7.22.15", "@types/express": "^4.17.17", + "@types/feather-icons": "^4.29.2", "@types/primer__octicons": "^19.6.0", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", @@ -56,4 +58,4 @@ "bugs": { "url": "https://github.com/DenverCoder1/custom-icon-badges/issues" } -} \ No newline at end of file +} diff --git a/server/controllers/controller.ts b/server/controllers/controller.ts index 8f26bb70..e932a5b2 100644 --- a/server/controllers/controller.ts +++ b/server/controllers/controller.ts @@ -5,8 +5,10 @@ import { fetchDefaultBadge, fetchErrorBadge, } from '../services/fetchBadges'; -import iconDatabase from '../services/iconDatabase'; -import octicons from '../services/octicons'; +import IconDatabaseService from '../services/icons/iconDatabase'; +import OcticonsService from '../services/icons/OcticonsService'; +import FeatherIconsService from '../services/icons/FeatherIconsService'; +import IconsService from '../services/icons/IconsService'; /** * List all icons in the database @@ -15,7 +17,7 @@ import octicons from '../services/octicons'; */ async function listIconsJSON(_req: Request, res: Response): Promise { res.status(200).json({ - icons: await iconDatabase.getIcons(), + icons: await IconDatabaseService.getIcons(), }); } @@ -29,8 +31,21 @@ async function getBadge(req: Request, res: Response): Promise { try { // get logo from query as a string, use nothing if multiple or empty const slug = typeof req.query.logo === 'string' ? req.query.logo : ''; + // get logoSource from query as a string + const logoSource = typeof req.query.logoSource === 'string' ? req.query.logoSource : ''; + // check for logoColor in query + const logoColor = typeof req.query.logoColor === 'string' ? req.query.logoColor : null; // check if slug exists - const item = slug ? octicons.getIcon(slug) || await iconDatabase.getIcon(slug) : null; + let item = null; + if (slug) { + // get item from requested source, default to octicons + const iconService: typeof IconsService = { + octicons: OcticonsService, + feather: FeatherIconsService, + }[logoSource] ?? OcticonsService; + // default to database if logoSource is not in the requested source + item = await iconService.getIcon(slug, logoColor) ?? await IconDatabaseService.getIcon(slug, logoColor); + } // get badge for item response = await fetchBadgeFromRequest(req, item); } catch (error) { @@ -43,7 +58,7 @@ async function getBadge(req: Request, res: Response): Promise { } } // get content type - const contentType = response.headers.get('content-type') || 'image/svg+xml'; + const contentType = response.headers.get('content-type') ?? 'image/svg+xml'; // send response res.status(response.status).type(contentType).send(await response.text()); } @@ -90,7 +105,7 @@ async function postIcon(req: Request, res: Response): Promise { } // check for slug in the database - const item = octicons.getIcon(slug) || await iconDatabase.getIcon(slug); + const item = await OcticonsService.getIcon(slug) ?? await IconDatabaseService.getIcon(slug); // Get default badge with the logo set to the slug const defaultBadgeResponse = await fetchDefaultBadge(slug); @@ -112,7 +127,7 @@ async function postIcon(req: Request, res: Response): Promise { // All checks passed, add the icon to the database console.info(`Creating new icon for ${slug}`); // create item - const body = await iconDatabase.insertIcon(slug, type, data); + const body = await IconDatabaseService.insertIcon(slug, type, data); // return success response res.status(200).json({ type: 'success', diff --git a/server/services/fetchBadges.ts b/server/services/fetchBadges.ts index afbeab87..446828e6 100644 --- a/server/services/fetchBadges.ts +++ b/server/services/fetchBadges.ts @@ -1,7 +1,6 @@ import fetch, { Response } from 'node-fetch'; import { Request } from 'express'; import { ParsedQs } from 'qs'; -import setLogoColor from './setLogoColor'; /** * Error class for exceptions caused during building and fetching of badges @@ -64,12 +63,7 @@ function buildQueryStringFromItem( if (item === null) { return buildQueryString(req.query); } - let { data } = item; - // check for logoColor parameter if it is SVG - if (typeof req.query.logoColor === 'string' && item.type === 'svg+xml') { - const color = req.query.logoColor; - data = setLogoColor(data, color); - } + const { data } = item; // replace logo with data url in query const newQuery = replacedLogoQuery(req, item.type, data); // remove "host" parameter from query string diff --git a/server/services/iconDatabase.ts b/server/services/iconDatabase.ts deleted file mode 100644 index 78ccdb0b..00000000 --- a/server/services/iconDatabase.ts +++ /dev/null @@ -1,51 +0,0 @@ -import monk, { FindResult } from 'monk'; - -const DB_NAME = 'custom-icon-badges'; -const DB_URL = process.env.DB_URL || `mongodb://localhost:27017/${DB_NAME}`; -const db = monk(DB_URL); -const icons = db.get('icons'); -icons.createIndex({ slug: 1 }, { unique: true }); - -/** - * Check if a slug exists in the database and return the icon if it does - * @param {string} slug The slug to look for - * @returns {Object} The icon data if it exists, null otherwise - */ -function getIcon(slug: string): Promise<{ slug: string, type: string, data: string } | null> { - // find slug in database, returns null if not found - return icons.findOne({ slug: slug.toLowerCase() }); -} - -/** - * Insert a new icon into the database - * @param {string} slug The slug to use for the icon - * @param {string} type The type of icon to use (eg. 'png', 'svg+xml') - * @param {string} data The base64 encoded data for the icon - * @returns {Object} The icon data - */ -async function insertIcon(slug: string, type: string, data: string): - Promise<{ slug: string, type: string, data: string }> { - // create item - const item = { slug: slug.toLowerCase(), type, data }; - // insert item - await icons.insert(item); - // return inserted item - return item; -} - -/** - * Get all icons from the database - * @returns {FindResult} The icons in the database - */ -function getIcons(): Promise> { - // return all items - return icons.find({}, { sort: { _id: -1 } }); -} - -const defaultExport = { - getIcon, - getIcons, - insertIcon, -}; - -export default defaultExport; diff --git a/server/services/icons/FeatherIconsService.ts b/server/services/icons/FeatherIconsService.ts new file mode 100644 index 00000000..b564d60b --- /dev/null +++ b/server/services/icons/FeatherIconsService.ts @@ -0,0 +1,24 @@ +import feather, { FeatherIcon } from 'feather-icons'; +import IconsService from './IconsService'; +import { normalizeColor } from '../logoColor'; + +class FeatherIconsService extends IconsService { + public static async getIcon(slug: string, color: string|null = null): + Promise<{ slug: string; type: string; data: string } | null> { + const normalized = slug.toLowerCase(); + if (!(normalized in feather.icons)) { + return null; + } + // @ts-ignore - icon is checked above + const icon: FeatherIcon = feather.icons[normalized]; + const normalizedColor = normalizeColor(color ?? 'whitesmoke'); + const svg = icon.toSvg({ color: normalizedColor }); + return { + slug: icon.name, + type: 'svg+xml', + data: Buffer.from(svg, 'utf8').toString('base64'), + }; + } +} + +export default FeatherIconsService; diff --git a/server/services/icons/IconsService.ts b/server/services/icons/IconsService.ts new file mode 100644 index 00000000..18c25304 --- /dev/null +++ b/server/services/icons/IconsService.ts @@ -0,0 +1,15 @@ +abstract class IconsService { + /** + * Get an icon by slug + * + * @param slug The slug of the icon to get + * @param color The color to set in the SVG or null to use default + */ + // eslint-disable-next-line no-unused-vars + public static async getIcon(slug: string, color: string|null = null): + Promise<{ slug: string; type: string; data: string } | null> { + throw new Error('Not implemented'); + } +} + +export default IconsService; diff --git a/server/services/icons/OcticonsService.ts b/server/services/icons/OcticonsService.ts new file mode 100644 index 00000000..76910218 --- /dev/null +++ b/server/services/icons/OcticonsService.ts @@ -0,0 +1,24 @@ +import octicons, { IconName } from '@primer/octicons'; +import IconsService from './IconsService'; +import { normalizeColor } from '../logoColor'; + +class OcticonsService extends IconsService { + public static async getIcon(slug: string, color: string|null = null): + Promise<{ slug: string; type: string; data: string } | null> { + const normalized = slug.toLowerCase(); + if (!(normalized in octicons)) { + return null; + } + const icon = octicons[normalized as IconName]; + const normalizedColor = normalizeColor(color ?? 'whitesmoke'); + // add 'xmlns' and 'fill' attribute to the svg + const svg = icon.toSVG().replace(' { + // find slug in database, returns null if not found + const icon = await icons.findOne({ slug: slug.toLowerCase() }); + // return null if not found + if (!icon) { + return null; + } + // set color if it is not null + const data = color ? setLogoColor(icon.data, color) : icon.data; + // return icon + return { + slug: icon.slug, + type: icon.type, + data, + }; + } + + /** + * Insert a new icon into the database + * @param {string} slug The slug to use for the icon + * @param {string} type The type of icon to use (eg. 'png', 'svg+xml') + * @param {string} data The base64 encoded data for the icon + * @returns {Object} The icon data + */ + public static async insertIcon(slug: string, type: string, data: string): + Promise<{ slug: string, type: string, data: string }> { + // create item + const item = { slug: slug.toLowerCase(), type, data }; + // insert item + await icons.insert(item); + // return inserted item + return item; + } + + /** + * Get all icons from the database + * @returns {FindResult} The icons in the database + */ + public static async getIcons(): + Promise> { + // return all items + return icons.find({}, { sort: { _id: -1 } }); + } +} + +export default IconDatabaseService; diff --git a/server/services/setLogoColor.ts b/server/services/logoColor.ts similarity index 91% rename from server/services/setLogoColor.ts rename to server/services/logoColor.ts index 53c853f9..289f6a0c 100644 --- a/server/services/setLogoColor.ts +++ b/server/services/logoColor.ts @@ -38,7 +38,7 @@ function isHexColor(color: string): boolean { * @param color color to normalize * @returns {string} normalized color */ -function normalizeColor(color: string): string { +export function normalizeColor(color: string): string { // if color is in the list of named colors, return the hex color if (color in namedColors) { return namedColors[color]; @@ -61,15 +61,13 @@ function normalizeColor(color: string): string { * @param logoColor color to fill with * @returns {string} base64 encoded svg with fill color */ -function setLogoColor(data: string, logoColor: string): string { +export function setLogoColor(data: string, logoColor: string): string { // decode base64 const decoded = Buffer.from(data, 'base64').toString('utf8'); // validate color const color = normalizeColor(logoColor); // insert style tag after opening svg tag - const svg = decoded.replace(/]*>/, `$&`); + const svg = decoded.replace(/]*>/, `$&`); // convert back to base64 return Buffer.from(svg, 'utf8').toString('base64'); } - -export default setLogoColor; diff --git a/server/services/octicons.ts b/server/services/octicons.ts deleted file mode 100644 index 2ce2d90d..00000000 --- a/server/services/octicons.ts +++ /dev/null @@ -1,27 +0,0 @@ -import octicons, { IconName } from '@primer/octicons'; - -/** - * Check if a slug exists in Octicons and return the icon if it does - * @param {string} slug The slug to look for - * @returns {Object} The icon data if it exists, null otherwise - */ -function getIcon(slug: string): { slug: string, type: string, data: string } | null { - const normalized = slug.toLowerCase(); - if (!(normalized in octicons)) { - return null; - } - const icon = octicons[normalized as IconName]; - // add 'xmlns' and 'fill' attribute to the svg - const svg = icon.toSVG().replace('