Skip to content

Commit c1c020e

Browse files
authored
Merge pull request #368 from Krakazybik/feature-docusarus-og-plugin
PROMO-251: OpenGraph dynamic image plugin prototype.
2 parents 9cfa263 + cacd82a commit c1c020e

File tree

11 files changed

+499
-1
lines changed

11 files changed

+499
-1
lines changed

website/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
"react": "^17.0.1",
3737
"react-cookie-consent": "^6.4.1",
3838
"react-dom": "^17.0.1",
39+
"sha1": "^1.1.1",
40+
"sharp": "^0.29.3",
41+
"superstruct": "^0.15.3",
42+
"text-to-svg": "^3.1.5",
3943
"url-loader": "^4.1.1"
4044
},
4145
"browserslist": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Docusaurus OpenGraph image generator plugin
2+
Как это работает?
3+
Для манипуляций с изображениями используется [sharp](https://sharp.pixelplumbing.com/) работающий через `libvips`. На этапе postBuild, когда у нас всё собрано, получаем инфу из doc плагина и на её основе генерируем изображение с необходимыми нам дополнительными слоями. Сами изображения и слои описываем в наших шаблонах. Если нам нужно применить конкретный шаблон для конкретного документа - используем правила.
4+
5+
## Usage
6+
Шаблоны помещаются в папку `open-graph-templates`. Для настройки плагина используется `config.json`.
7+
Шаблонов может быть сколько угодно много, но при этом (Важно!) `basic` обязательный для работы плагина.
8+
9+
10+
### Templates folder files listing.
11+
```sh
12+
└── website/
13+
└── open-graph-tempaltes/
14+
# required
15+
├── basic
16+
| ├── font.ttf
17+
| ├── preview.png
18+
| └── template.json
19+
|
20+
└── config.json
21+
```
22+
23+
### Templates configuration file example:
24+
**config.json**
25+
```json
26+
{
27+
"outputDir": "assets/og",
28+
"textWidthLimit": 1100,
29+
"quality": 70,
30+
"rules": [
31+
{
32+
"name": "basic",
33+
"priority": 0,
34+
"pattern": "."
35+
},
36+
{
37+
"name": "gray",
38+
"priority": 1,
39+
"pattern": "^concepts*"
40+
},
41+
{
42+
"name": "gray",
43+
"priority": 2,
44+
"pattern": "^about*"
45+
}
46+
]
47+
}
48+
49+
```
50+
`outputDir` - выходная директория в билде для наших картинок.
51+
`textWidthLimit` - ограничение по длине текстовой строки, при превышении которого шрифт будет скейлиться.
52+
`quality` - качество(компрессия JPEG Quality) картинки на выходе.
53+
`rules` - правила(их может быть сколько угодно много), по которым будет применяться тот или иной шаблон в зависимости от пути до документа(позволяет нам для разных эндпоинтов док, создавать свои превьюшки):
54+
- `rules.name` - имя шаблона (название папки в open-graph-templates)
55+
- `rules.priority` - приоритет, правило с более высоким приоритетом замещает собой правила с более низким.
56+
- `rules.pattern` - RegExp шаблон, по которому сравнивается путь документа для применения того или иного правила.
57+
58+
59+
### Template configuration example:
60+
**template.json**
61+
```json
62+
{
63+
"image": "preview.png",
64+
"font": "arial.ttf",
65+
"layout": [
66+
{
67+
"type": "text",
68+
"name": "title",
69+
"fontSize": 80,
70+
"fill": "white",
71+
"stroke": "white",
72+
"top": 400,
73+
"left": 200
74+
}
75+
]
76+
}
77+
```
78+
`image` - путь до изображения на основе которого шаблон будет делать preview.
79+
`font` - используемый файл шрифта.
80+
`layout` - описывает накладываемые слои и их расположение:
81+
- `layout.type` - задел на будущее пока только "text", в дальнейшем планируется image, postEffect и тд.
82+
- `layout.name` - на данный момент для text типа получает поле из плагина doc, полезные варианты: title, description, formattedLastUpdatedAt остальные поля очень спорны для применения.
83+
- `layout.fontSize` - размер шрифта для слоя с типом text.
84+
- `layout.fill` - цвет заливки букв для слоя с типом text.
85+
- `layout.stroke` - цвет контура букв для слоя с типом text.
86+
- `layout.top`, `layout.left` - отступ нашего слоя от края изображения.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const fs = require("fs");
2+
const { object, string, number, array, is } = require("superstruct");
3+
const { objectFromBuffer } = require("./utils");
4+
5+
function getConfig(path, encode = "utf-8") {
6+
const config = objectFromBuffer(fs.readFileSync(`${path}\\config.json`, encode));
7+
if (!validateConfig(config)) {
8+
console.error("Config validation error");
9+
return;
10+
}
11+
return config;
12+
}
13+
14+
const Rule = object({
15+
name: string(),
16+
priority: number(),
17+
pattern: string(),
18+
});
19+
20+
const Config = object({
21+
outputDir: string(),
22+
textWidthLimit: number(),
23+
quality: number(),
24+
rules: array(Rule),
25+
});
26+
27+
function validateConfig(config) {
28+
if (is(config, Config)) {
29+
return config.rules.reduce((validationResult, rule) => {
30+
if (!is(rule, Rule)) return false;
31+
return validationResult;
32+
}, true);
33+
}
34+
return false;
35+
}
36+
37+
module.exports = { getConfig };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const textToSVG = require("text-to-svg");
2+
3+
function createFontsMapFromTemplates(templates) {
4+
const fonts = new Map();
5+
templates.forEach((template) => {
6+
if (!fonts.has(template.params.font)) {
7+
fonts.set(
8+
template.params.font,
9+
textToSVG.loadSync(`${template.path}\\${template.name}\\${template.params.font}`),
10+
);
11+
}
12+
});
13+
return fonts;
14+
}
15+
16+
function createSVGText(
17+
font,
18+
text,
19+
{ fontSize = 72, fill = "white", stroke = "white" },
20+
widthLimit = 1000,
21+
) {
22+
const attributes = { fill, stroke };
23+
const options = { fontSize, anchor: "top", attributes };
24+
25+
/* If font width more than widthLimit => scale font width to ~90% of widthLimit */
26+
if (widthLimit) {
27+
const { width } = font.getMetrics(text, options);
28+
if (width > widthLimit)
29+
options.fontSize = Math.trunc((fontSize * 0.9) / (width / widthLimit));
30+
}
31+
32+
return font.getSVG(text, options);
33+
}
34+
module.exports = { createSVGText, createFontsMapFromTemplates };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const sharp = require("sharp");
2+
3+
function getTemplateImageId(template) {
4+
return `${template.name}_${template.params.image}`;
5+
}
6+
7+
function createImagePipeline(file) {
8+
// TODO: Apply effects, compression and etc.
9+
// TODO: File validation?
10+
return sharp(file);
11+
}
12+
13+
function createImageFromTemplate({ path, name, params }) {
14+
return createImagePipeline(`${path}\\${name}\\${params.image}`);
15+
}
16+
17+
function createImagesMapFromTemplates(templates) {
18+
const images = new Map();
19+
templates.forEach((template) => {
20+
const imageId = getTemplateImageId(template);
21+
22+
if (!images.has(imageId)) {
23+
images.set(imageId, createImageFromTemplate(template));
24+
}
25+
});
26+
return images;
27+
}
28+
29+
module.exports = { createImagesMapFromTemplates, getTemplateImageId };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
const fs = require("fs");
2+
const sha1 = require("sha1");
3+
const { getTemplates } = require("./template");
4+
const { createLayoutLayers } = require("./layout");
5+
const { createFontsMapFromTemplates } = require("./font");
6+
const { createImagesMapFromTemplates, getTemplateImageId } = require("./image");
7+
const { getConfig } = require("./config");
8+
const { getTemplateNameByRules } = require("./rules");
9+
10+
module.exports = function ({ templatesDir }) {
11+
const initData = bootstrap(templatesDir);
12+
if (!initData) {
13+
console.error("OpenGraph plugin exit with error.");
14+
return;
15+
}
16+
17+
const { config } = initData;
18+
19+
return {
20+
name: "docusaurus-plugin-open-graph-image",
21+
async postBuild({ plugins, outDir, i18n }) {
22+
const docsPlugin = plugins.find(
23+
(plugin) => plugin.name === "docusaurus-plugin-content-docs",
24+
);
25+
26+
if (!docsPlugin) throw new Error("Docusaurus Doc plugin not found.");
27+
28+
const previewOutputDir = `${outDir}\\${config.outputDir}`;
29+
fs.mkdir(previewOutputDir, { recursive: true }, (error) => {
30+
if (error) throw error;
31+
});
32+
33+
const docsContent = docsPlugin.content;
34+
const docsVersions = docsContent.loadedVersions;
35+
docsVersions.forEach((version) => {
36+
const { docs } = version;
37+
38+
docs.forEach((document) => {
39+
generateImageFromDoc(initData, document, i18n.currentLocale, previewOutputDir);
40+
});
41+
});
42+
},
43+
};
44+
};
45+
46+
function bootstrap(templatesDir) {
47+
const isProd = process.env.NODE_ENV === "production";
48+
if (!isProd) return;
49+
50+
if (!templatesDir) {
51+
console.error("Wrong templatesDir option.");
52+
return;
53+
}
54+
55+
const templates = getTemplates(templatesDir);
56+
if (!templates) return;
57+
58+
const config = getConfig(templatesDir);
59+
if (!config) return;
60+
61+
// TODO: File not found exception?
62+
const fonts = createFontsMapFromTemplates(templates);
63+
const images = createImagesMapFromTemplates(templates);
64+
65+
return { templates, config, fonts, images };
66+
}
67+
68+
async function generateImageFromDoc(initData, doc, locale, outputDir) {
69+
const { templates, config, images, fonts } = initData;
70+
const { id, title } = doc;
71+
72+
const hashFileName = sha1(id + locale);
73+
74+
const templateName = getTemplateNameByRules(id, config.rules);
75+
76+
const template = templates.find((template) => template.name === templateName);
77+
78+
const previewImage = await images.get(getTemplateImageId(template)).clone();
79+
80+
const previewFont = fonts.get(template.params.font);
81+
82+
const textLayers = createLayoutLayers(
83+
doc,
84+
template.params.layout,
85+
previewFont,
86+
config.textWidthLimit,
87+
);
88+
89+
try {
90+
await previewImage.composite(textLayers);
91+
await previewImage
92+
.jpeg({
93+
quality: config.quality,
94+
chromaSubsampling: "4:4:4",
95+
})
96+
.toFile(`${outputDir}\\${hashFileName}.jpg`);
97+
} catch (error) {
98+
console.error(error, id, title, hashFileName);
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const { createSVGText } = require("./font");
2+
3+
function createLayoutLayers(doc, layout, previewFont, textWidthLimit) {
4+
/* Check for all layers names exist in doc fields */
5+
if (layout.some((layer) => !doc[layer.name])) {
6+
console.error(`Wrong template config.`);
7+
return;
8+
}
9+
10+
return layout.map((layer) => {
11+
const layoutOptions = {
12+
fontSize: layer.fontSize,
13+
fill: layer.fill,
14+
stroke: layer.stroke,
15+
};
16+
17+
return {
18+
input: Buffer.from(
19+
createSVGText(previewFont, doc[layer.name], layoutOptions, textWidthLimit),
20+
),
21+
top: layer.top,
22+
left: layer.left,
23+
};
24+
});
25+
}
26+
27+
module.exports = { createLayoutLayers };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
function getTemplateNameByRules(path, rules) {
2+
const filteredRules = rules.filter((rule) => new RegExp(rule.pattern).test(path));
3+
const sortedRules = filteredRules.sort((a, b) => b.priority - a.priority);
4+
return sortedRules[0]?.name || "basic";
5+
}
6+
7+
module.exports = { getTemplateNameByRules };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const fs = require("fs");
2+
const { object, string, number, array, is } = require("superstruct");
3+
const { objectFromBuffer } = require("./utils");
4+
5+
const dirIgnore = ["config.json"];
6+
7+
function getTemplates(templatesDir, encode = "utf8") {
8+
const templatesDirNames = fs
9+
.readdirSync(templatesDir)
10+
.filter((fileName) => !dirIgnore.includes(fileName));
11+
12+
// TODO: check file exist
13+
const templates = templatesDirNames.map((templateName) => ({
14+
name: templateName,
15+
path: templatesDir,
16+
params: objectFromBuffer(
17+
fs.readFileSync(`${templatesDir}\\${templateName}\\template.json`, encode),
18+
),
19+
}));
20+
21+
if (!templates.some(validateTemplate)) {
22+
console.error("Templates validation error.");
23+
return;
24+
}
25+
26+
return templates;
27+
}
28+
29+
// TODO: May be with postEffects, images and etc? (optional fontSize, fill and etc)
30+
const Layout = object({
31+
type: string(),
32+
name: string(),
33+
fontSize: number(),
34+
fill: string(),
35+
stroke: string(),
36+
top: number(),
37+
left: number(),
38+
});
39+
40+
const Template = object({
41+
image: string(),
42+
font: string(),
43+
layout: array(Layout),
44+
});
45+
46+
function validateTemplate({ params }) {
47+
if (is(params, Template)) {
48+
if (params.layout.length === 0) return false;
49+
50+
return params.layout.reduce((validationResult, layout) => {
51+
if (!is(layout, Layout)) return false;
52+
return validationResult;
53+
}, true);
54+
}
55+
56+
return false;
57+
}
58+
59+
module.exports = { getTemplates };

0 commit comments

Comments
 (0)