@luego/nunjucks-luego-image-extension
v3.0.0
Published
Nunjucks {% img %} extension: responsive <picture> elements with sharp-powered image export, inline blur placeholders and deterministic output
Downloads
496
Readme
Luego Image {% img %}
Extensão Nunjucks para criar elementos <picture> responsivos e otimizados. A partir de uma imagem de origem, gera variantes em diferentes tamanhos (desktop/tablet/mobile), formatos, densidades (1x/2x) e placeholders blur inline — e exporta tudo via sharp.
🚀 Versão 3.0.0
- ✅ Pipeline de export dentro do pacote:
exportImages()substitui as ~100 linhas de sharp no gulpfile - ✅ Placeholder blur inline (base64): embutido no
srccomo data URI — elimina 1 request HTTP por imagem e o bug de extensão do placeholder comformat != webp - ✅ HTML determinístico: IDs derivados do slug (sha1) — builds idênticos geram bytes idênticos (melhor para CDN/cache/diff)
- ✅ Segurança: validação por whitelist (format/fit/density/loading/fetchpriority), escape de atributos HTML, bloqueio de path traversal no
src, sem re-render Nunjucks do HTML gerado - ✅ Limpeza de órfãos:
exportImages({ clean: true })remove variantes que não pertencem mais a nenhum{% img %} - ✅ Config injetável: passe o
luego.config.jspelo construtor (sem depender de../../../nem env var) - ✅ Sem Emmet: HTML gerado diretamente (dependência
@emmetio/expand-abbreviationremovida) - ✅ Cache correto no watch: componente invalidado quando o mtime da imagem muda
- ✅ Testes:
npm test(node:test, sem dependências extras)
⚠️ Breaking changes (2.x → 3.0)
| Mudança | Migração |
| ------- | -------- |
| getItens() removido | Use getItems() |
| classSlug removido (sempre gerava string vazia) | Sem substituto — era código morto |
| Campos mortos removidos de getItems() (mobileSrc, desktopSrc2x, placeholderSrc, source) | Nenhum era renderizado |
| Argumentos posicionais title/currentTag removidos | Nunca foram usados no HTML |
| Arquivos -@placeholder-*.webp não são mais gerados | Placeholder agora é data URI inline |
| Parâmetros inválidos (format, fit, etc. fora da whitelist) geram erro | Corrija o valor no template |
| attr aceita somente a sintaxe [nome="valor"] | Outros formatos geram erro |
Instalação
npm i @luego/nunjucks-luego-image-extension
# ou
yarn add @luego/nunjucks-luego-image-extensionRequer Node >= 18 e nunjucks.Environment com { async: true }.
Configuração
Passe o config diretamente no construtor (recomendado):
const NunjucksImageExtension = require('@luego/nunjucks-luego-image-extension');
const ImageExtensionHelper = new NunjucksImageExtension(nunjucks, nunjucksEnv, {}, [], {
config: require('./luego.config.js'),
imagesRoot: path.resolve('src/assets/.images'),
});
nunjucksEnv.addExtension('ImageExtension', ImageExtensionHelper);Sem options.config, a extensão tenta LUEGO_CONFIG_PATH e depois ../../../luego.config.js (comportamento legado).
Integração com o Gulpfile
A extensão funciona em duas fases:
- Build HTML — o
{% img %}lê os metadados da imagem, monta o<picture>(com placeholder blur inline) e registra a imagem emimgsToExport. - Export —
exportImages()gera todas as variantes (perfis +@2x) com sharp, pulando as que já estão atualizadas (mtime) e removendo órfãs.
let exportedImages = {}; // cache por slug (getItems())
let imgsToExport = []; // fila de exportação (getImgsToExport())
const ImageExtensionHelper = new NunjucksImageExtension(nunjucks, nunjucksEnv, exportedImages, imgsToExport, {
config: require('./luego.config.js'),
imagesRoot: path.resolve('src/assets/.images'),
});
// Após o build:html, persista a fila (permite rodar o export isoladamente):
fs.writeFileSync('.tmp/images-export.json', JSON.stringify(ImageExtensionHelper.getImgsToExport()));
// build:exportImages
gulp.task('build:exportImages', async () => {
const queue = JSON.parse(fs.readFileSync('.tmp/images-export.json', 'utf8'));
const stats = await ImageExtensionHelper.exportImages({
queue,
destDir: 'build/assets/images/exported',
clean: true,
});
console.log(`Images: ${stats.exported} exported, ${stats.skipped} up-to-date, ${stats.cleaned} orphans removed`);
});API
new ImageExtension(nunjucks, env, exportedImages, imgsToExport, options?)
| Option | Default | Descrição |
| ------ | ------- | --------- |
| config | LUEGO_CONFIG_PATH → ../../../luego.config.js | Objeto do luego.config.js |
| imagesRoot | <cwd>/src/assets/.images | Raiz das imagens; src fora dela é bloqueado |
exportImages({ queue, destDir, clean, concurrency })
Exporta as variantes da fila. queue default = getImgsToExport(); destDir obrigatório; clean remove órfãos; concurrency default = nº de CPUs. Retorna { exported, skipped, cleaned }.
getItems() / getImgsToExport()
getItems() → { [slug]: { needsAlt, mtimeMs, component } }. getImgsToExport() → [{ imageSrc, imageName, config }].
Como usar
A pasta root das imagens é /src/assets/.images/
{% img 'imagem.jpg' %}
{% img src='imagem.jpg' %}Resultado — <picture> com <source> por breakpoint + <img> com lazy loading e placeholder blur inline:
<picture id="img-3fa9c21b" class="wrap-img wrap-imagem">
<source media="(max-width: 430px)" data-srcset="assets/images/exported/slug-mobile.webp, assets/images/exported/[email protected] 2x" sizes="...">
<source media="(max-width: 1024px)" data-srcset="assets/images/exported/slug-tablet.webp, ..." sizes="...">
<source media="(min-width: 1025px)" data-srcset="assets/images/exported/slug-desktop.webp, ..." sizes="...">
<img
class="imagem lazyload"
src="data:image/webp;base64,..."
data-srcset="assets/images/exported/slug-desktop.webp 1x, assets/images/exported/[email protected] 2x"
loading="lazy"
fetchpriority="auto"
height="1280"
width="1920"
data-image-type="exported"
data-image-orientation="landscape"
style="--ratio: 1.5; --ratio-xs: 1.5;"
alt="imagem">
<div class="blur"></div>
<style>@media (min-width: 1025px) { #img-3fa9c21b { width: 1920px } }...</style>
</picture>Atributos
| Atributo | Tipo | Default | Descrição |
| ------ | ---- | ------- | ----------- |
| src | string | — obrigatório — | Caminho relativo a imagesRoot ou URL http(s):// externa |
| width | number | Tamanho original (max 1920px) | Somente width mantém o aspect ratio |
| height | number | Tamanho original | Somente height mantém o aspect ratio |
| fit | string | cover | cover / contain / fill / inside / outside |
| quality | number | 80 | De 1 a 100 |
| format | string | webp | jpg / jpeg / webp / png / gif / avif |
| loading | string | lazy | lazy / eager |
| fetchpriority | string | auto | auto / high / low |
| class | string | nome do arquivo | Classe CSS do <img> e <picture> |
| alt | string | imagem | Sem alt explícito, o item recebe needsAlt: true |
| density | string | Auto por breakpoint | 1x / 2x |
| attr | string | '' | Atributos extras no <picture>: [data-x="1"][hidden] |
| breakpoints | object | Config padrão | Breakpoints customizados |
Exemplos
{# Imagem above-the-fold (LCP): eager + preload com imagesrcset #}
{% img 'hero.jpg', loading='eager', fetchpriority='high' %}
{# Dimensões e recorte #}
{% img 'imagem.jpg', width=500 %}
{% img 'imagem.jpg', width=400, height=500, fit='contain' %}
{# Formato e qualidade #}
{% img 'imagem.jpg', format='avif', quality=70 %}
{# Breakpoints customizados #}
{% img 'imagem.jpg', breakpoints={
mobile: { breakpoint: '(max-width: 430px)', width: 400 },
tablet: { breakpoint: '(max-width: 1024px)', width: 800 }
} %}
{# Atributos extras no <picture> #}
{% img 'imagem.jpg', attr='[data-aos="fade-up"]' %}
{# Imagem externa (renderizada sem processamento) #}
{% img 'https://cdn.exemplo.com/banner.jpg', alt='Banner' %}Tratamento de erros
Qualquer erro (src inexistente, parâmetro inválido, path traversal) vira um comentário HTML — o build não quebra:
<!-- {% img %} error: Quality must be between 1 and 100 (got "150") -->Observações importantes
- Imagens acima de 1920px de largura são redimensionadas para 1920px (use width para forçar outro tamanho)
- Recomendamos sempre informar alt — itens sem alt explícito recebem
needsAlt: truepara futura geração via IA - Os placeholders blur são data URIs webp (6% do tamanho, quality 25) — nenhum arquivo extra é gerado
- O HTML de saída é determinístico: dois builds da mesma fonte produzem bytes idênticos
Flag needsAlt
const items = ImageExtensionHelper.getItems();
for (const [slug, item] of Object.entries(items)) {
if (item.needsAlt) {
// Futuramente: enviar imagem para um modelo com visão e obter alt-text
}
}Roadmap / ideias
- Multi-formato:
<source type="image/avif">com fallback webp por breakpoint - Hash de conteúdo no lugar de mtime para o skip de export (mtime não sobrevive a checkouts de CI)
- Alt automático via IA consumindo a flag
needsAlt - Descriptors
w+sizesem vez de media queries com larguras fixas (deixa o browser escolher a variante)
