-
Notifications
You must be signed in to change notification settings - Fork 178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PROMO-251: OpenGraph dynamic image plugin prototype. #368
Changes from all commits
8225f9c
ef2eb88
d287b7b
3f2700a
420114c
669a72f
b2b1980
bf6acae
34be04e
c1af82f
f34a49c
bce6c12
e26094c
13428ae
3fbbd6c
f0412fc
cacd82a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
# Docusaurus OpenGraph image generator plugin | ||
azinit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Как это работает? | ||
Для манипуляций с изображениями используется [sharp](https://sharp.pixelplumbing.com/) работающий через `libvips`. На этапе postBuild, когда у нас всё собрано, получаем инфу из doc плагина и на её основе генерируем изображение с необходимыми нам дополнительными слоями. Сами изображения и слои описываем в наших шаблонах. Если нам нужно применить конкретный шаблон для конкретного документа - используем правила. | ||
|
||
## Usage | ||
Шаблоны помещаются в папку `open-graph-templates`. Для настройки плагина используется `config.json`. | ||
Шаблонов может быть сколько угодно много, но при этом (Важно!) `basic` обязательный для работы плагина. | ||
|
||
|
||
### Templates folder files listing. | ||
```sh | ||
└── website/ | ||
└── open-graph-tempaltes/ | ||
# required | ||
├── basic | ||
| ├── font.ttf | ||
| ├── preview.png | ||
| └── template.json | ||
| | ||
└── config.json | ||
``` | ||
|
||
### Templates configuration file example: | ||
**config.json** | ||
```json | ||
{ | ||
"outputDir": "assets/og", | ||
"textWidthLimit": 1100, | ||
"quality": 70, | ||
"rules": [ | ||
{ | ||
"name": "basic", | ||
"priority": 0, | ||
"pattern": "." | ||
}, | ||
{ | ||
"name": "gray", | ||
"priority": 1, | ||
"pattern": "^concepts*" | ||
}, | ||
{ | ||
"name": "gray", | ||
"priority": 2, | ||
"pattern": "^about*" | ||
} | ||
] | ||
} | ||
|
||
``` | ||
`outputDir` - выходная директория в билде для наших картинок. | ||
`textWidthLimit` - ограничение по длине текстовой строки, при превышении которого шрифт будет скейлиться. | ||
`quality` - качество(компрессия JPEG Quality) картинки на выходе. | ||
`rules` - правила(их может быть сколько угодно много), по которым будет применяться тот или иной шаблон в зависимости от пути до документа(позволяет нам для разных эндпоинтов док, создавать свои превьюшки): | ||
- `rules.name` - имя шаблона (название папки в open-graph-templates) | ||
- `rules.priority` - приоритет, правило с более высоким приоритетом замещает собой правила с более низким. | ||
- `rules.pattern` - RegExp шаблон, по которому сравнивается путь документа для применения того или иного правила. | ||
|
||
|
||
### Template configuration example: | ||
**template.json** | ||
```json | ||
{ | ||
"image": "preview.png", | ||
"font": "arial.ttf", | ||
"layout": [ | ||
{ | ||
"type": "text", | ||
"name": "title", | ||
"fontSize": 80, | ||
"fill": "white", | ||
"stroke": "white", | ||
"top": 400, | ||
"left": 200 | ||
} | ||
] | ||
} | ||
``` | ||
`image` - путь до изображения на основе которого шаблон будет делать preview. | ||
`font` - используемый файл шрифта. | ||
`layout` - описывает накладываемые слои и их расположение: | ||
- `layout.type` - задел на будущее пока только "text", в дальнейшем планируется image, postEffect и тд. | ||
- `layout.name` - на данный момент для text типа получает поле из плагина doc, полезные варианты: title, description, formattedLastUpdatedAt остальные поля очень спорны для применения. | ||
- `layout.fontSize` - размер шрифта для слоя с типом text. | ||
- `layout.fill` - цвет заливки букв для слоя с типом text. | ||
- `layout.stroke` - цвет контура букв для слоя с типом text. | ||
- `layout.top`, `layout.left` - отступ нашего слоя от края изображения. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
const fs = require("fs"); | ||
const { object, string, number, array, is } = require("superstruct"); | ||
const { objectFromBuffer } = require("./utils"); | ||
|
||
function getConfig(path, encode = "utf-8") { | ||
const config = objectFromBuffer(fs.readFileSync(`${path}\\config.json`, encode)); | ||
if (!validateConfig(config)) { | ||
console.error("Config validation error"); | ||
return; | ||
} | ||
return config; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Чтение и валидация нашего основного конфига плагина. |
||
} | ||
|
||
const Rule = object({ | ||
name: string(), | ||
priority: number(), | ||
pattern: string(), | ||
}); | ||
|
||
const Config = object({ | ||
outputDir: string(), | ||
textWidthLimit: number(), | ||
quality: number(), | ||
rules: array(Rule), | ||
}); | ||
|
||
function validateConfig(config) { | ||
if (is(config, Config)) { | ||
return config.rules.reduce((validationResult, rule) => { | ||
if (!is(rule, Rule)) return false; | ||
return validationResult; | ||
}, true); | ||
} | ||
return false; | ||
} | ||
|
||
module.exports = { getConfig }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
const textToSVG = require("text-to-svg"); | ||
|
||
function createFontsMapFromTemplates(templates) { | ||
const fonts = new Map(); | ||
templates.forEach((template) => { | ||
if (!fonts.has(template.params.font)) { | ||
fonts.set( | ||
template.params.font, | ||
textToSVG.loadSync(`${template.path}\\${template.name}\\${template.params.font}`), | ||
); | ||
} | ||
}); | ||
return fonts; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Создаём коллекции уникальных шрифтов (уникальность проверяется по имени файла лул), если шрифт уже подгрузили, игнорируем. |
||
} | ||
|
||
function createSVGText( | ||
font, | ||
text, | ||
{ fontSize = 72, fill = "white", stroke = "white" }, | ||
widthLimit = 1000, | ||
) { | ||
const attributes = { fill, stroke }; | ||
const options = { fontSize, anchor: "top", attributes }; | ||
|
||
/* If font width more than widthLimit => scale font width to ~90% of widthLimit */ | ||
if (widthLimit) { | ||
const { width } = font.getMetrics(text, options); | ||
if (width > widthLimit) | ||
options.fontSize = Math.trunc((fontSize * 0.9) / (width / widthLimit)); | ||
} | ||
|
||
return font.getSVG(text, options); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Создаём SVG из нашего текста. Если длина текста выходит за лимит указанный в конфиге textWidthLimit, скейлим шрифт до ~90% от максимально разрешенной ширины. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. В коде комментарием тоже бы описать, достаточно неочевидный момент) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Так? =) |
||
module.exports = { createSVGText, createFontsMapFromTemplates }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
const sharp = require("sharp"); | ||
|
||
function getTemplateImageId(template) { | ||
return `${template.name}_${template.params.image}`; | ||
} | ||
|
||
function createImagePipeline(file) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Т.к. малясь абстрактно выглядят) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. + свои мысли по расширению функционала закинешь, чтобы точно не потерялось) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Комменты в коде, или здесь? =) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. в коде |
||
// TODO: Apply effects, compression and etc. | ||
// TODO: File validation? | ||
return sharp(file); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Задел на будущее. Пока ничего полезного не делает, кроме как создание sharp объекта. |
||
|
||
function createImageFromTemplate({ path, name, params }) { | ||
return createImagePipeline(`${path}\\${name}\\${params.image}`); | ||
} | ||
|
||
function createImagesMapFromTemplates(templates) { | ||
const images = new Map(); | ||
templates.forEach((template) => { | ||
const imageId = getTemplateImageId(template); | ||
|
||
if (!images.has(imageId)) { | ||
images.set(imageId, createImageFromTemplate(template)); | ||
} | ||
}); | ||
return images; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Создание мапы уникальных sharp pipeline'ов для наших картинок. (проверка на уникальность пока бесполезна, т.к. в layout кроме текста ничего добавлять пока что нельзя, и у нас по сути нет других картинок в шаблоне). |
||
} | ||
|
||
module.exports = { createImagesMapFromTemplates, getTemplateImageId }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
const fs = require("fs"); | ||
const sha1 = require("sha1"); | ||
const { getTemplates } = require("./template"); | ||
const { createLayoutLayers } = require("./layout"); | ||
const { createFontsMapFromTemplates } = require("./font"); | ||
const { createImagesMapFromTemplates, getTemplateImageId } = require("./image"); | ||
const { getConfig } = require("./config"); | ||
const { getTemplateNameByRules } = require("./rules"); | ||
|
||
module.exports = function ({ templatesDir }) { | ||
const initData = bootstrap(templatesDir); | ||
if (!initData) { | ||
console.error("OpenGraph plugin exit with error."); | ||
return; | ||
} | ||
|
||
const { config } = initData; | ||
|
||
return { | ||
name: "docusaurus-plugin-open-graph-image", | ||
async postBuild({ plugins, outDir, i18n }) { | ||
const docsPlugin = plugins.find( | ||
(plugin) => plugin.name === "docusaurus-plugin-content-docs", | ||
); | ||
|
||
if (!docsPlugin) throw new Error("Docusaurus Doc plugin not found."); | ||
|
||
const previewOutputDir = `${outDir}\\${config.outputDir}`; | ||
fs.mkdir(previewOutputDir, { recursive: true }, (error) => { | ||
if (error) throw error; | ||
}); | ||
|
||
const docsContent = docsPlugin.content; | ||
const docsVersions = docsContent.loadedVersions; | ||
docsVersions.forEach((version) => { | ||
const { docs } = version; | ||
|
||
docs.forEach((document) => { | ||
generateImageFromDoc(initData, document, i18n.currentLocale, previewOutputDir); | ||
}); | ||
}); | ||
}, | ||
}; | ||
}; | ||
|
||
function bootstrap(templatesDir) { | ||
const isProd = process.env.NODE_ENV === "production"; | ||
if (!isProd) return; | ||
|
||
if (!templatesDir) { | ||
console.error("Wrong templatesDir option."); | ||
return; | ||
} | ||
|
||
const templates = getTemplates(templatesDir); | ||
if (!templates) return; | ||
|
||
const config = getConfig(templatesDir); | ||
if (!config) return; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Инициализируем шаблоны и конфиги. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Дополню для других, что плагинах докузавра так и принято описывать инициализацию плагинов, так что тут все конвенционально +-) |
||
// TODO: File not found exception? | ||
const fonts = createFontsMapFromTemplates(templates); | ||
const images = createImagesMapFromTemplates(templates); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Создаём коллекции шрифтов и картинок, которые в дальнейшем будем клонить и потом с ними работать. |
||
return { templates, config, fonts, images }; | ||
} | ||
|
||
async function generateImageFromDoc(initData, doc, locale, outputDir) { | ||
const { templates, config, images, fonts } = initData; | ||
const { id, title } = doc; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Генерируем хэш имя файла из идентификатора документа ( выглядит как "about/alternatives/atomic-design" и локали "ru"/"en") There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Или она на контекст основной функции завязана? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. В принципе можно и вынести, но у docusaurus всё суётся в одно место =) Но и завязана малясь, да =) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ну можно такой вариант, например. Но немного не как в docusaurus :D И лучше чем initData пока ничего в голову не пришло =)) const fs = require("fs");
const sha1 = require("sha1");
const { getTemplates } = require("./template");
const { createLayoutLayers } = require("./layout");
const { createFontsMapFromTemplates } = require("./font");
const { createImagesMapFromTemplates, getTemplateImageId } = require("./image");
const { getConfig } = require("./config");
const { getTemplateNameByRules } = require("./rules");
module.exports = function ({ templatesDir }) {
const initData = bootstrap();
if (!initData) {
console.error("OpenGraph plugin exit with error.");
return;
}
const { config } = initData;
return {
name: "docusaurus-plugin-open-graph-image",
async postBuild({ plugins, outDir, i18n }) {
const docsPlugin = plugins.find(
(plugin) => plugin.name === "docusaurus-plugin-content-docs",
);
if (!docsPlugin) throw new Error("Docusaurus Doc plugin not found.");
const previewOutputDir = `${outDir}\\${config.outputDir}`;
fs.mkdir(previewOutputDir, { recursive: true }, (error) => {
if (error) throw error;
});
const docsContent = docsPlugin.content;
const docsVersions = docsContent.loadedVersions;
docsVersions.forEach((version) => {
const { docs } = version;
docs.forEach((item) => {
generateImageFromDoc(initData, item, i18n.currentLocale, previewOutputDir);
});
});
},
};
};
function bootstrap(templatesDir) {
const isProd = process.env.NODE_ENV === "production";
if (!isProd) return;
if (!templatesDir) {
console.error("Wrong templatesDir option.");
return;
}
const templates = getTemplates(templatesDir);
if (!templates) return;
const config = getConfig(templatesDir);
if (!config) return;
// TODO: File not found exception?
const fonts = createFontsMapFromTemplates(templates);
const images = createImagesMapFromTemplates(templates);
return { templates, config, fonts, images };
}
async function generateImageFromDoc(initData, doc, locale, outputDir) {
const { id, title } = doc;
const { templates, config, images, fonts } = initData;
const hashFileName = sha1(id + locale);
const templateName = getTemplateNameByRules(id, config.rules);
const template = templates.find((item) => item.name === templateName);
const previewImage = await images.get(getTemplateImageId(template)).clone();
const previewFont = fonts.get(template.params.font);
const textLayers = createLayoutLayers(
doc,
template.params.layout,
previewFont,
config.textWidthLimit,
);
try {
await previewImage.composite(textLayers);
await previewImage
.jpeg({
quality: config.quality,
chromaSubsampling: "4:4:4",
})
.toFile(`${outputDir}\\${hashFileName}.jpg`);
} catch (error) {
console.error(error, id, title, hashFileName);
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Да вродь оч даже) Я тем более лишь про generateImageFromDoc просил Но и так оч классно выглядит 👍 |
||
const hashFileName = sha1(id + locale); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. далее идём в правила и возвращаем самый высокоприоритетный шаблон согласно правилам |
||
const templateName = getTemplateNameByRules(id, config.rules); | ||
|
||
const template = templates.find((template) => template.name === templateName); | ||
|
||
const previewImage = await images.get(getTemplateImageId(template)).clone(); | ||
|
||
const previewFont = fonts.get(template.params.font); | ||
|
||
const textLayers = createLayoutLayers( | ||
doc, | ||
template.params.layout, | ||
previewFont, | ||
config.textWidthLimit, | ||
); | ||
|
||
try { | ||
await previewImage.composite(textLayers); | ||
await previewImage | ||
.jpeg({ | ||
quality: config.quality, | ||
chromaSubsampling: "4:4:4", | ||
}) | ||
.toFile(`${outputDir}\\${hashFileName}.jpg`); | ||
} catch (error) { | ||
console.error(error, id, title, hashFileName); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
const { createSVGText } = require("./font"); | ||
|
||
function createLayoutLayers(doc, layout, previewFont, textWidthLimit) { | ||
/* Check for all layers names exist in doc fields */ | ||
if (layout.some((layer) => !doc[layer.name])) { | ||
console.error(`Wrong template config.`); | ||
return; | ||
} | ||
|
||
return layout.map((layer) => { | ||
const layoutOptions = { | ||
fontSize: layer.fontSize, | ||
fill: layer.fill, | ||
stroke: layer.stroke, | ||
}; | ||
|
||
return { | ||
input: Buffer.from( | ||
createSVGText(previewFont, doc[layer.name], layoutOptions, textWidthLimit), | ||
), | ||
top: layer.top, | ||
left: layer.left, | ||
}; | ||
}); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Пока что работает только для текста, проверяем есть ли у нас вообще поле в doc указанное в 'name' конфига шаблона. Если всё гуд - создаём объект с SVG и оффсетами для каждого слоя указанного в lyaouts. Если что-то пошло не так, не пытаемся даже вернуть рабочие слои. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Представь работу с изображением в ФШ, примерно тоже самое, есть базовое изображение и мы слоями накладываем сверху наш текст и etc |
||
|
||
module.exports = { createLayoutLayers }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
function getTemplateNameByRules(path, rules) { | ||
const filteredRules = rules.filter((rule) => new RegExp(rule.pattern).test(path)); | ||
const sortedRules = filteredRules.sort((a, b) => b.priority - a.priority); | ||
return sortedRules[0]?.name || "basic"; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Пытаемся применить шаблоны (pattern) из правил, сортируем их по приоритету и возвращаем самый первый, если что-то пошло не так и ему поплохело вернём "basic" |
||
|
||
module.exports = { getTemplateNameByRules }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
const fs = require("fs"); | ||
const { object, string, number, array, is } = require("superstruct"); | ||
const { objectFromBuffer } = require("./utils"); | ||
|
||
const dirIgnore = ["config.json"]; | ||
|
||
function getTemplates(templatesDir, encode = "utf8") { | ||
const templatesDirNames = fs | ||
.readdirSync(templatesDir) | ||
.filter((fileName) => !dirIgnore.includes(fileName)); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Достаём из папки названия наших шаблонов, игнорируя при этом всё, что в dirIgnore, пока что там только config.json |
||
// TODO: check file exist | ||
const templates = templatesDirNames.map((templateName) => ({ | ||
name: templateName, | ||
path: templatesDir, | ||
params: objectFromBuffer( | ||
fs.readFileSync(`${templatesDir}\\${templateName}\\template.json`, encode), | ||
), | ||
})); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Читаем наши шаблоны, согласно полученным папкам. И возвращаем массив объектов с прочитанными данными, названием шаблона и путем до него. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Тогда мб лучше в сам код занести) |
||
if (!templates.some(validateTemplate)) { | ||
console.error("Templates validation error."); | ||
return; | ||
} | ||
|
||
return templates; | ||
} | ||
|
||
// TODO: May be with postEffects, images and etc? (optional fontSize, fill and etc) | ||
const Layout = object({ | ||
type: string(), | ||
name: string(), | ||
fontSize: number(), | ||
fill: string(), | ||
stroke: string(), | ||
top: number(), | ||
left: number(), | ||
}); | ||
|
||
const Template = object({ | ||
image: string(), | ||
font: string(), | ||
layout: array(Layout), | ||
}); | ||
|
||
function validateTemplate({ params }) { | ||
if (is(params, Template)) { | ||
if (params.layout.length === 0) return false; | ||
|
||
return params.layout.reduce((validationResult, layout) => { | ||
if (!is(layout, Layout)) return false; | ||
return validationResult; | ||
}, true); | ||
} | ||
|
||
return false; | ||
} | ||
|
||
module.exports = { getTemplates }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thoughts:
Эх, вынести бы как-нибудь, чтобы зависимости только во время постбилда пригождались, и не лезли в основной билд 🤔Но out-of-scope, мб другие что подскажут
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Krakazybik мб ты что тут подскажешь, ты ведь достаточно много плагинов докузавра наизучал за это время)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
думаю =)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
либо монорепу (лул), либо "sharp" "superstruct" "text-to-svg": можно в dev кинуть =) и отучить линтер матюгаться по import/no-extraneous-dependencies для папки плагина =)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ну пока так оставим, мб @Postamentovich @GhostMayor что-то подскажут
(для контекста опять же - это docusaurus-plugin, который чисто во время сборки нужен; потом отдельно в npm опубликуем наверное)