mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-11-02 12:05:57 +01:00
feat: dynamic category icon generation
When a category is retrieved via activitypub, NodeBB will now generate an SVG and PNG representation of the category utilising the "icon", "color", and "bgColor" values. closes #12507
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
"@isaacs/ttlcache": "1.4.1",
|
||||
"@nodebb/spider-detector": "2.0.3",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@textcomplete/contenteditable": "0.1.13",
|
||||
"@textcomplete/core": "0.1.13",
|
||||
"@textcomplete/textarea": "0.1.13",
|
||||
@@ -130,6 +131,7 @@
|
||||
"rtlcss": "4.3.0",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sass": "1.79.3",
|
||||
"satori": "^0.11.1",
|
||||
"semver": "7.6.3",
|
||||
"serve-favicon": "2.5.0",
|
||||
"sharp": "0.32.6",
|
||||
|
||||
@@ -224,12 +224,12 @@ Mocks.actors.category = async (cid) => {
|
||||
};
|
||||
}
|
||||
|
||||
let icon = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`;
|
||||
const filename = path.basename(utils.decodeHTMLEntities(icon));
|
||||
let icon = await categories.icons.get(cid);
|
||||
icon = icon.get('png');
|
||||
icon = {
|
||||
type: 'Image',
|
||||
mediaType: mime.getType(filename),
|
||||
url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`,
|
||||
mediaType: 'image/png',
|
||||
url: `${nconf.get('url')}${icon}`,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
109
src/categories/icon.js
Normal file
109
src/categories/icon.js
Normal file
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs/promises');
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
const { default: satori } = require('satori');
|
||||
const { Resvg } = require('@resvg/resvg-js');
|
||||
|
||||
const utils = require('../utils');
|
||||
|
||||
const categories = module.parent.exports;
|
||||
const Icons = module.exports;
|
||||
|
||||
Icons._constants = Object.freeze({
|
||||
extensions: ['svg', 'png'],
|
||||
});
|
||||
|
||||
Icons.get = async (cid) => {
|
||||
try {
|
||||
const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`));
|
||||
await Promise.all(paths.map(async (path) => {
|
||||
await fs.access(path);
|
||||
}));
|
||||
|
||||
return new Map(Object.entries({
|
||||
svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`,
|
||||
png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`,
|
||||
}));
|
||||
} catch (e) {
|
||||
return await Icons.regenerate(cid);
|
||||
}
|
||||
};
|
||||
|
||||
Icons.flush = async (cid) => {
|
||||
winston.verbose(`[categories/icons] Flushing ${cid}.`);
|
||||
const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`));
|
||||
|
||||
await Promise.all(paths.map((async path => await fs.rm(path, { force: true }))));
|
||||
};
|
||||
|
||||
Icons.regenerate = async (cid) => {
|
||||
winston.verbose(`[categories/icons] Regenerating ${cid}.`);
|
||||
const { icon, color, bgColor } = await categories.getCategoryData(cid);
|
||||
|
||||
const fontPaths = new Map(Object.entries({
|
||||
regular: path.join(utils.getFontawesomePath(), 'webfonts/fa-regular-400.ttf'),
|
||||
solid: path.join(utils.getFontawesomePath(), 'webfonts/fa-solid-900.ttf'),
|
||||
}));
|
||||
const fontBuffers = new Map(Object.entries({
|
||||
regular: await fs.readFile(fontPaths.get('regular')),
|
||||
solid: await fs.readFile(fontPaths.get('solid')),
|
||||
}));
|
||||
|
||||
// Retrieve unicode codepoint (hex) and weight
|
||||
let metadata = await fs.readFile(path.join(utils.getFontawesomePath(), 'metadata/icon-families.json'), 'utf-8');
|
||||
metadata = JSON.parse(metadata); // needs try..catch wrapper
|
||||
let iconString = icon.slice(3);
|
||||
iconString = iconString.split(' ').shift(); // sometimes multiple classes saved; use first
|
||||
const fontWeight = iconString.endsWith('-o') ? 400 : 900;
|
||||
iconString = iconString.endsWith('-o') ? iconString.slice(0, -2) : iconString;
|
||||
const { unicode } = metadata[iconString] || metadata.comments; // fall back to fa-comments
|
||||
|
||||
// Generate and save SVG
|
||||
const svg = await satori({
|
||||
type: 'div',
|
||||
props: {
|
||||
children: String.fromCodePoint(`0x${unicode}`),
|
||||
style: {
|
||||
width: '128px',
|
||||
height: '128px',
|
||||
color,
|
||||
background: bgColor,
|
||||
fontSize: '64px',
|
||||
fontWeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
width: 128,
|
||||
height: 128,
|
||||
fonts: [{
|
||||
name: 'Font Awesome 6 Free',
|
||||
data: fontBuffers.get('regular'),
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
}, {
|
||||
name: 'Font Awesome 6 Free',
|
||||
data: fontBuffers.get('solid'),
|
||||
weight: 900,
|
||||
style: 'normal',
|
||||
}],
|
||||
});
|
||||
await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.svg`), svg);
|
||||
|
||||
// Generate and save PNG
|
||||
const resvg = new Resvg(Buffer.from(svg));
|
||||
const pngData = resvg.render();
|
||||
const pngBuffer = pngData.asPng();
|
||||
|
||||
await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.png`), pngBuffer);
|
||||
|
||||
return new Map(Object.entries({
|
||||
svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`,
|
||||
png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`,
|
||||
}));
|
||||
};
|
||||
@@ -24,6 +24,8 @@ require('./update')(Categories);
|
||||
require('./watch')(Categories);
|
||||
require('./search')(Categories);
|
||||
|
||||
Categories.icons = require('./icon');
|
||||
|
||||
Categories.exists = async function (cids) {
|
||||
return await db.exists(
|
||||
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`
|
||||
|
||||
@@ -39,6 +39,11 @@ module.exports = function (Categories) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await updateCategoryField(cid, key, category[key]);
|
||||
}
|
||||
|
||||
if (['icon', 'color', 'bgColor'].some(prop => Object.keys(modifiedFields).includes(prop))) {
|
||||
Categories.icons.flush(cid);
|
||||
}
|
||||
|
||||
plugins.hooks.fire('action:category.update', { cid: cid, modified: category });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user