Разработка кастомного плагина Eleventy
Плагины Eleventy — это JavaScript-функции, которые принимают eleventyConfig и регистрируют в нём фильтры, shortcodes, коллекции, обработчики событий, трансформации. В отличие от Jekyll-плагинов на Ruby, здесь всё в одной экосистеме Node.js: можно использовать весь npm и async/await без ограничений.
Анатомия плагина
// myplugin.js — минимальный плагин
module.exports = function(eleventyConfig, options = {}) {
// Опции с дефолтами
const config = {
outputDir: options.outputDir || "_site/assets",
quality: options.quality || 80,
formats: options.formats || ["webp", "jpeg"],
...options,
};
// Регистрация компонентов плагина
eleventyConfig.addFilter("myFilter", function(value) {
return value;
});
eleventyConfig.addShortcode("myShortcode", function(arg) {
return `<span>${arg}</span>`;
});
// Возврат не обязателен, но можно вернуть публичный API
};
Подключение:
// eleventy.config.js
const myPlugin = require("./src/_plugins/myplugin");
module.exports = function(eleventyConfig) {
eleventyConfig.addPlugin(myPlugin, {
quality: 85,
formats: ["avif", "webp", "jpeg"],
});
};
Плагин оптимизации изображений
Расширение официального @11ty/eleventy-img с кастомной логикой:
// src/_plugins/images.js
const Image = require("@11ty/eleventy-img");
const path = require("path");
module.exports = function(eleventyConfig, options = {}) {
const defaults = {
widths: [320, 640, 960, 1280, 1920],
formats: ["avif", "webp", "jpeg"],
outputDir: "./_site/assets/images/",
urlPath: "/assets/images/",
sharpOptions: { quality: 82 },
sharpWebpOptions: { quality: 80 },
sharpAvifOptions: { quality: 70 },
};
const cfg = { ...defaults, ...options };
// Async shortcode для одиночного изображения
eleventyConfig.addAsyncShortcode("img", async function(
src,
alt,
sizes = "(max-width: 768px) 100vw, 1200px",
classList = ""
) {
if (!src) throw new Error(`Missing src for img shortcode in ${this.page?.inputPath}`);
// Определить абсолютный путь
const srcPath = src.startsWith("http")
? src
: path.join("src", src);
try {
const metadata = await Image(srcPath, cfg);
return Image.generateHTML(metadata, {
alt: alt || "",
sizes,
loading: "lazy",
decoding: "async",
class: classList,
});
} catch (e) {
console.warn(`[img] Не удалось обработать изображение: ${src}`, e.message);
return `<img src="${src}" alt="${alt || ""}" loading="lazy">`;
}
});
// Синхронный shortcode для OG-изображений (предгенерированных)
eleventyConfig.addNunjucksAsyncShortcode("ogImage", async function(src, alt) {
const metadata = await Image(path.join("src", src), {
widths: [1200],
formats: ["jpeg"],
outputDir: cfg.outputDir,
urlPath: cfg.urlPath,
});
return metadata.jpeg[0].url;
});
// Фильтр для получения URL изображения заданного размера
eleventyConfig.addNunjucksAsyncFilter("imageUrl", async function(src, width, callback) {
const metadata = await Image(path.join("src", src), {
widths: [width],
formats: ["jpeg"],
outputDir: cfg.outputDir,
urlPath: cfg.urlPath,
});
callback(null, metadata.jpeg[0].url);
});
};
Плагин для генерации страниц из внешних данных
Получение данных из API и создание страниц при сборке:
// src/_plugins/cms-pages.js
const fetch = require("node-fetch");
module.exports = function(eleventyConfig, options = {}) {
const { apiUrl, collection, template, permalinkFn } = options;
// Регистрируем глобальные данные из API
eleventyConfig.addGlobalData(`${collection}Items`, async function() {
const response = await fetch(apiUrl, {
headers: { "Authorization": `Bearer ${process.env.CMS_TOKEN}` }
});
if (!response.ok) {
console.warn(`[cms-pages] API вернул ${response.status}`);
return [];
}
const data = await response.json();
return data.items || data;
});
// Трансформация для добавления мета-данных
eleventyConfig.addTransform("addPageMeta", function(content, outputPath) {
if (!outputPath?.endsWith(".html")) return content;
// Добавить last-modified meta
return content.replace(
'</head>',
`<meta name="last-modified" content="${new Date().toISOString()}">\n</head>`
);
});
};
// src/_data/services.js — альтернативный подход через _data
module.exports = async function() {
const res = await fetch("https://api.example.com/services");
const data = await res.json();
// Кешировать на время разработки
return data;
};
Плагин синтаксического подсвета с дополнительными возможностями
// src/_plugins/codeblock.js
const { readFileSync } = require("fs");
const path = require("path");
module.exports = function(eleventyConfig) {
// Shortcode для вставки кода из файла
eleventyConfig.addShortcode("codeFile", function(filePath, lang, highlight = "") {
const fullPath = path.join(process.cwd(), filePath);
let code;
try {
code = readFileSync(fullPath, "utf8").trim();
} catch {
return `<!-- File not found: ${filePath} -->`;
}
// Экранирование для HTML
const escaped = code
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return `<div class="code-block" data-lang="${lang}">
<div class="code-block__header">
<span class="code-block__filename">${path.basename(filePath)}</span>
<button class="code-block__copy" data-code="${escaped.replace(/"/g, '"')}">
Копировать
</button>
</div>
<pre class="language-${lang}"><code class="language-${lang}">${escaped}</code></pre>
</div>`;
});
// Shortcode для diff-блоков
eleventyConfig.addPairedShortcode("diff", function(content, lang = "diff") {
const lines = content.split("\n").map(line => {
if (line.startsWith("+")) return `<span class="diff-add">${line}</span>`;
if (line.startsWith("-")) return `<span class="diff-remove">${line}</span>`;
return `<span class="diff-context">${line}</span>`;
});
return `<pre class="diff-block"><code>${lines.join("\n")}</code></pre>`;
});
};
Плагин для мультиязычности
// src/_plugins/i18n.js
const { readFileSync, existsSync } = require("fs");
const path = require("path");
const yaml = require("js-yaml");
module.exports = function(eleventyConfig, options = {}) {
const { defaultLang = "ru", langs = ["ru", "en"], localesDir = "src/_i18n" } = options;
// Загрузить все переводы
const translations = {};
langs.forEach(lang => {
const filePath = path.join(localesDir, `${lang}.yaml`);
if (existsSync(filePath)) {
translations[lang] = yaml.load(readFileSync(filePath, "utf8"));
}
});
// Фильтр перевода
eleventyConfig.addFilter("t", function(key, lang) {
const currentLang = lang || this.ctx?.lang || defaultLang;
const keys = key.split(".");
let value = translations[currentLang];
for (const k of keys) {
value = value?.[k];
if (value === undefined) break;
}
if (value === undefined) {
console.warn(`[i18n] Перевод не найден: ${key} (${currentLang})`);
return key;
}
return value;
});
// Shortcode для переключателя языков
eleventyConfig.addShortcode("langSwitcher", function(currentUrl, currentLang) {
const links = langs.map(lang => {
const url = lang === defaultLang
? currentUrl.replace(`/${currentLang}/`, "/")
: `/${lang}${currentUrl}`;
return `<a href="${url}" hreflang="${lang}" ${lang === currentLang ? 'aria-current="true"' : ''}>${lang.toUpperCase()}</a>`;
});
return `<nav class="lang-switcher" aria-label="Выбор языка">${links.join("")}</nav>`;
});
};
Тестирование плагина
// tests/plugin.test.js (Jest)
const Eleventy = require("@11ty/eleventy");
test("img shortcode генерирует picture element", async () => {
const elev = new Eleventy("./test/input", "./test/output", {
config(eleventyConfig) {
require("../src/_plugins/images")(eleventyConfig);
}
});
const result = await elev.toJSON();
const page = result.find(p => p.url === "/test/");
expect(page.content).toContain("<picture>");
expect(page.content).toContain('type="image/avif"');
});
Публикация на npm
{
"name": "eleventy-plugin-mycompany-images",
"version": "1.0.0",
"description": "Image optimization plugin for Eleventy",
"main": "src/index.js",
"peerDependencies": {
"@11ty/eleventy": "^2.0.0"
},
"keywords": ["eleventy", "plugin", "images"]
}
Сроки
Простой плагин (набор фильтров, 2–3 shortcodes) — 1–2 дня. Плагин с async-операциями (изображения, внешний API) — 3–5 дней. Полноценный плагин с тестами, документацией, публикацией на npm — 1–2 недели.







