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 5 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
6 changes: 6 additions & 0 deletions website/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ module.exports = {
},
],
"linebreak-style": [2, "unix"],
"import/no-unresolved": [
"error",
{
ignore: ["^@theme-original"],
},
],
azinit marked this conversation as resolved.
Show resolved Hide resolved
},
settings: {
"import/resolver": {
Expand Down
7 changes: 7 additions & 0 deletions website/docusaurus.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require("dotenv").config();
const path = require("path");
const { REDIRECTS, SECTIONS, LEGACY_ROUTES } = require("./routes.config");

const DOMAIN = "https://feature-sliced.design/";
azinit marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -254,6 +255,12 @@ const plugins = [
},
],
process.env.HOTJAR_ID && "docusaurus-plugin-hotjar", // For preventing crashing
[
path.resolve(__dirname, "./plugins/docusaurus-plugin-open-graph-image"),
{
templatesDir: path.resolve(__dirname, "open-graph-templates"),
},
],
azinit marked this conversation as resolved.
Show resolved Hide resolved
].filter(Boolean);

/** @type {Config["themeConfig"]["algolia"]} */
Expand Down
Binary file not shown.
Binary file added website/open-graph-templates/basic/arial.ttf
Binary file not shown.
Binary file added website/open-graph-templates/basic/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions website/open-graph-templates/basic/template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"image": "preview.png",
"font": "arial.ttf",
"layout": [
{
"type": "text",
"name": "title",
"fontSize": 80,
"fill": "white",
"stroke": "white",
"top": 400,
"left": 200
}
]
}
azinit marked this conversation as resolved.
Show resolved Hide resolved
22 changes: 22 additions & 0 deletions website/open-graph-templates/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"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*"
}
]
}
azinit marked this conversation as resolved.
Show resolved Hide resolved
Binary file added website/open-graph-templates/gray/Kanit-Bold.ttf
Binary file not shown.
Binary file added website/open-graph-templates/gray/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions website/open-graph-templates/gray/template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"image": "preview.png",
"font": "Kanit-bold.ttf",
"layout": [
{
"type": "text",
"name": "title",
"fontSize": 80,
"fill": "white",
"stroke": "white",
"top": 200,
"left": 200
}
]
}
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` - отступ нашего слоя от края изображения.
12 changes: 12 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,12 @@
const fs = require("fs");
const { objectFromBuffer, validateConfig } = 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.

Чтение и валидация нашего основного конфига плагина.

}
module.exports = { getConfig };
33 changes: 33 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,33 @@
const textToSVG = require("text-to-svg");

function createFontsMapFromTemplates(templates) {
const fonts = new Map();
templates.forEach((item) => {
if (!fonts.has(item.params.font)) {
fonts.set(
item.params.font,
textToSVG.loadSync(`${item.path}\\${item.name}\\${item.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 (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 };
23 changes: 23 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,23 @@
const sharp = require("sharp");

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((item) => {
if (!images.has(`${item.name}_${item.params.image}`)) {
images.set(`${item.name}_${item.params.image}`, createImageFromTemplate(item));
Krakazybik marked this conversation as resolved.
Show resolved Hide resolved
}
});
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 };
90 changes: 90 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,90 @@
const fs = require("fs");
const sha1 = require("sha1");
const { getTemplates } = require("./template");
const { validateTemplate } = require("./utils");
const { createLayoutLayers } = require("./layout");
const { createFontsMapFromTemplates } = require("./font");
const { createImagesMapFromTemplates } = require("./image");
const { getConfig } = require("./config");
const { getTemplateNameByRules } = require("./rules");

module.exports = function (context, { 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.some(validateTemplate)) {
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.

Создаём коллекции шрифтов и картинок, которые в дальнейшем будем клонить и потом с ними работать.

async function generateImageFromDoc(doc, locale, outputDir) {
const { id, title } = doc;

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.

Генерируем хэш имя файла из идентификатора документа ( выглядит как "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 templateName = getTemplateNameByRules(id, config.rules);

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 template = templates.find((item) => item.name === templateName);

const previewImage = await images.get(`${template.name}_${template.params.image}`).clone();

const previewFont = fonts.get(template.params.font);

const textLayers = createLayoutLayers(
doc,
template.params.layout,
previewFont,
config.textWidthLimit,
);

Copy link
Member Author

Choose a reason for hiding this comment

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

Здесь собственно достаём шаблон по имени, клонируем наш пайплайн изображения, достаём шрифты и в конце создаём все наши слои, описанные в layout конфига шаблона.

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 Author

Choose a reason for hiding this comment

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

Совмещаем наше изображение со слоями, применяем компрессию и записываем в файл.


return {
name: "docusaurus-plugin-open-graph-image",
async postBuild({ plugins, outDir, i18n }) {
const docsPlugin = plugins.find(
(plugin) => plugin.name === "docusaurus-plugin-content-docs",
);

const previewOutputDir = `${outDir}\\${config.outputDir}`;
fs.mkdir(previewOutputDir, { recursive: true }, (error) => {
if (error) throw error;
});

if (docsPlugin) {
const docsContent = docsPlugin.content;
const docsVersions = docsContent.loadedVersions;
docsVersions.forEach((version) => {
const { docs } = version;

docs.forEach((item) => {
generateImageFromDoc(item, i18n.currentLocale, previewOutputDir);
});
});
Copy link
Member Author

Choose a reason for hiding this comment

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

Для всех версий сайта (en/ru) генерируем наши картиночки.

}
},
};
};
30 changes: 30 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,30 @@
const { createSVGText } = require("./font");

function createLayoutLayers(doc, layout, previewFont, textWidthLimit) {
const layers = layout.map((item) => {
if (!Object.prototype.hasOwnProperty.call(doc, item.name)) {
console.error(`Wrong template config? Doc property ${item.name} not found.`);
Krakazybik marked this conversation as resolved.
Show resolved Hide resolved
return undefined;
Copy link
Member

Choose a reason for hiding this comment

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

nitpick: Можно и проще, как ниже делал)

Suggested change
return undefined;
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.

Вернул, т.к. снова наткнулся на то что матюгальник проснулся =))
image_2021-12-10_23-42-02

}

const layoutOptions = {
fontSize: item.fontSize,
fill: item.fill,
stroke: item.stroke,
};

return {
input: Buffer.from(
createSVGText(previewFont, doc[item.name], layoutOptions, textWidthLimit),
),
top: item.top,
left: item.left,
};
});

if (layers.includes(undefined)) return;

return layers;
}
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 };
Loading