Skip to content

Commit

Permalink
feat: Add support for Feather Icons (#959)
Browse files Browse the repository at this point in the history
  • Loading branch information
DenverCoder1 authored Sep 14, 2023
1 parent 4085102 commit b071baa
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 98 deletions.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

| | | | |
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -56,4 +58,4 @@
"bugs": {
"url": "https://github.com/DenverCoder1/custom-icon-badges/issues"
}
}
}
29 changes: 22 additions & 7 deletions server/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +17,7 @@ import octicons from '../services/octicons';
*/
async function listIconsJSON(_req: Request, res: Response): Promise<void> {
res.status(200).json({
icons: await iconDatabase.getIcons(),
icons: await IconDatabaseService.getIcons(),
});
}

Expand All @@ -29,8 +31,21 @@ async function getBadge(req: Request, res: Response): Promise<void> {
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) {
Expand All @@ -43,7 +58,7 @@ async function getBadge(req: Request, res: Response): Promise<void> {
}
}
// 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());
}
Expand Down Expand Up @@ -90,7 +105,7 @@ async function postIcon(req: Request, res: Response): Promise<void> {
}

// 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);
Expand All @@ -112,7 +127,7 @@ async function postIcon(req: Request, res: Response): Promise<void> {
// 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',
Expand Down
8 changes: 1 addition & 7 deletions server/services/fetchBadges.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 0 additions & 51 deletions server/services/iconDatabase.ts

This file was deleted.

24 changes: 24 additions & 0 deletions server/services/icons/FeatherIconsService.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions server/services/icons/IconsService.ts
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 24 additions & 0 deletions server/services/icons/OcticonsService.ts
Original file line number Diff line number Diff line change
@@ -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('<svg', `<svg xmlns="http://www.w3.org/2000/svg" fill="${normalizedColor}"`);
return {
slug: icon.symbol,
type: 'svg+xml',
data: Buffer.from(svg, 'utf8').toString('base64'),
};
}
}

export default OcticonsService;
59 changes: 59 additions & 0 deletions server/services/icons/iconDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import monk, { FindResult } from 'monk';
import IconsService from './IconsService';
import { setLogoColor } from '../logoColor';

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 });

class IconDatabaseService extends IconsService {
// 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> {
// 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<FindResult<{ slug: string, type: string, data: string }>> {
// return all items
return icons.find({}, { sort: { _id: -1 } });
}
}

export default IconDatabaseService;
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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(/<svg[^>]*>/, `$&<style>* { fill: ${color}!important; }</style>`);
const svg = decoded.replace(/<svg[^>]*>/, `$&<style>* { fill: ${color} !important; }</style>`);
// convert back to base64
return Buffer.from(svg, 'utf8').toString('base64');
}

export default setLogoColor;
27 changes: 0 additions & 27 deletions server/services/octicons.ts

This file was deleted.

Loading

0 comments on commit b071baa

Please sign in to comment.