mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-01-29 18:59:58 +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",
|
"@isaacs/ttlcache": "1.4.1",
|
||||||
"@nodebb/spider-detector": "2.0.3",
|
"@nodebb/spider-detector": "2.0.3",
|
||||||
"@popperjs/core": "2.11.8",
|
"@popperjs/core": "2.11.8",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@textcomplete/contenteditable": "0.1.13",
|
"@textcomplete/contenteditable": "0.1.13",
|
||||||
"@textcomplete/core": "0.1.13",
|
"@textcomplete/core": "0.1.13",
|
||||||
"@textcomplete/textarea": "0.1.13",
|
"@textcomplete/textarea": "0.1.13",
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
"rtlcss": "4.3.0",
|
"rtlcss": "4.3.0",
|
||||||
"sanitize-html": "2.13.0",
|
"sanitize-html": "2.13.0",
|
||||||
"sass": "1.79.3",
|
"sass": "1.79.3",
|
||||||
|
"satori": "^0.11.1",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
"serve-favicon": "2.5.0",
|
"serve-favicon": "2.5.0",
|
||||||
"sharp": "0.32.6",
|
"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`;
|
let icon = await categories.icons.get(cid);
|
||||||
const filename = path.basename(utils.decodeHTMLEntities(icon));
|
icon = icon.get('png');
|
||||||
icon = {
|
icon = {
|
||||||
type: 'Image',
|
type: 'Image',
|
||||||
mediaType: mime.getType(filename),
|
mediaType: 'image/png',
|
||||||
url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`,
|
url: `${nconf.get('url')}${icon}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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('./watch')(Categories);
|
||||||
require('./search')(Categories);
|
require('./search')(Categories);
|
||||||
|
|
||||||
|
Categories.icons = require('./icon');
|
||||||
|
|
||||||
Categories.exists = async function (cids) {
|
Categories.exists = async function (cids) {
|
||||||
return await db.exists(
|
return await db.exists(
|
||||||
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`
|
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
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await updateCategoryField(cid, key, category[key]);
|
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 });
|
plugins.hooks.fire('action:category.update', { cid: cid, modified: category });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user