Skip to content
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

Merged
merged 17 commits into from
Dec 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"react": "^17.0.1",
"react-cookie-consent": "^6.4.1",
"react-dom": "^17.0.1",
"sha1": "^1.1.1",
"sharp": "^0.29.3",
"superstruct": "^0.15.3",
"text-to-svg": "^3.1.5",
Comment on lines +39 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts: Эх, вынести бы как-нибудь, чтобы зависимости только во время постбилда пригождались, и не лезли в основной билд 🤔

Но out-of-scope, мб другие что подскажут

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Krakazybik мб ты что тут подскажешь, ты ведь достаточно много плагинов докузавра наизучал за это время)

Copy link
Member Author

@Krakazybik Krakazybik Dec 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

думаю =)

Copy link
Member Author

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 для папки плагина =)

Copy link
Member

@azinit azinit Dec 10, 2021

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 опубликуем наверное)

"url-loader": "^4.1.1"
},
"browserslist": {
Expand Down
86 changes: 86 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/Readme.md
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` - отступ нашего слоя от края изображения.
37 changes: 37 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/config.js
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;
Copy link
Member Author

Choose a reason for hiding this comment

The 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 };
34 changes: 34 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/font.js
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;
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Создаём SVG из нашего текста. Если длина текста выходит за лимит указанный в конфиге textWidthLimit, скейлим шрифт до ~90% от максимально разрешенной ширины.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В коде комментарием тоже бы описать, достаточно неочевидный момент)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так? =)

module.exports = { createSVGText, createFontsMapFromTemplates };
29 changes: 29 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/image.js
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(to-improve, non-blocking): Не крит, но здесь и далее по возможности бы добавил комменты к функциям

Т.к. малясь абстрактно выглядят)

Copy link
Member

@azinit azinit Dec 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+ свои мысли по расширению функционала закинешь, чтобы точно не потерялось)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Комменты в коде, или здесь? =)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

в коде

// TODO: Apply effects, compression and etc.
// TODO: File validation?
return sharp(file);
}
Copy link
Member Author

Choose a reason for hiding this comment

The 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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Создание мапы уникальных sharp pipeline'ов для наших картинок. (проверка на уникальность пока бесполезна, т.к. в layout кроме текста ничего добавлять пока что нельзя, и у нас по сути нет других картинок в шаблоне).

}

module.exports = { createImagesMapFromTemplates, getTemplateImageId };
100 changes: 100 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/index.js
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;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Инициализируем шаблоны и конфиги.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

The 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;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Генерируем хэш имя файла из идентификатора документа ( выглядит как "about/alternatives/atomic-design" и локали "ru"/"en")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(to-improve, non-blocking): А мб можно вынести функцию вне основной инициализации, чтоб не разбухала?

Или она на контекст основной функции завязана?

Copy link
Member Author

@Krakazybik Krakazybik Dec 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В принципе можно и вынести, но у docusaurus всё суётся в одно место =) Но и завязана малясь, да =)

Пример

Copy link
Member Author

@Krakazybik Krakazybik Dec 10, 2021

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Да вродь оч даже)

Я тем более лишь про generateImageFromDoc просил

Но и так оч классно выглядит 👍

const hashFileName = sha1(id + locale);

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
}
27 changes: 27 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/layout.js
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,
};
});
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Пока что работает только для текста, проверяем есть ли у нас вообще поле в doc указанное в 'name' конфига шаблона. Если всё гуд - создаём объект с SVG и оффсетами для каждого слоя указанного в lyaouts. Если что-то пошло не так, не пытаемся даже вернуть рабочие слои.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: А для понимания еще раз - что все таки собой эти слои представляют?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Представь работу с изображением в ФШ, примерно тоже самое, есть базовое изображение и мы слоями накладываем сверху наш текст и etc


module.exports = { createLayoutLayers };
7 changes: 7 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/rules.js
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";
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Пытаемся применить шаблоны (pattern) из правил, сортируем их по приоритету и возвращаем самый первый, если что-то пошло не так и ему поплохело вернём "basic"


module.exports = { getTemplateNameByRules };
59 changes: 59 additions & 0 deletions website/plugins/docusaurus-plugin-open-graph-image/template.js
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));

Copy link
Member Author

@Krakazybik Krakazybik Nov 28, 2021

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Читаем наши шаблоны, согласно полученным папкам. И возвращаем массив объектов с прочитанными данными, названием шаблона и путем до него.

Copy link
Member

Choose a reason for hiding this comment

The 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 };
Loading