Nette Assets: Konečně jednotné API pro vše od obrázků po Vite

včera od David Grudl  

Představuji Nette Assets – knihovnu, která kompletně transformuje práci se statickými soubory v Nette aplikacích. Vyzkoušenou a otestovanou na desítkách projektů.

Kolikrát jste psali kód jako tento?

<link rel="stylesheet" href="{$baseUrl}/css/style.css?v=7">

<img src="{$baseUrl}/images/logo.png?v=1699123456" width="200" height="100" alt="Logo">

Tedy ručně jste konstruovali URL, přidávali versioning parametry, zjišťovali a doplňovali správné rozměry obrázků a tak dále? To je minulost.

Během let vývoje webových aplikací jsem hledal řešení, které bude velmi jednoduché na použití, ale zároveň neomezeně otevřené pro rozšíření. Něco, co budu moci používat na všech svých webech – od jednoduchých prezentaček až po složité e-shopy. Po testování různých přístupů a osvědčení si řady principů na reálných projektech nakonec vzniklo Nette Assets.

S Nette Assets se stejný kód změní na:

{asset 'css/style.css'}

<img n:asset="images/logo.png" alt="Logo">

A to je vše! Knihovna automaticky:

✅ Detekuje rozměry obrázků a vloží je do HTML
✅ Přidá verzovací parametry podle času modifikace souboru
✅ Má nativní podporu Vite s Hot Module Replacement

Žádná konfigurace!

Pojďme začít tím nejjednodušším scénářem: máte složku www/assets/ plnou obrázků, CSS a JS souborů. Chcete je vkládat do stránek se správným verzováním a automatickými rozměry. Pak nemusíte vůbec nic konfigurovat, stačí nainstalovat Nette Assets:

composer require nette/assets

… a můžete začít používat všechny nové Latte značky a budou okamžitě fungovat:

{asset 'css/style.css'}
<img n:asset="images/logo.png">

Značku {asset} používám hlavně pro CSS a skripty, kdežto u obrázků mám raději n:asset, protože chci mít v šabloně explicitně viditelné HTML elementy jako <img> – je to však otázka osobních preferencí.

Šablona vygeneruje podobný kód:

<link rel="stylesheet" href="https://example.cz/assets/css/style.css?v=1670133401">
<img src="https://example.cz/assets/images/logo.png?v=1699123456" width="200" height="100">

Proč to verzování? Prohlížeče si ukládají statické soubory do cache a když soubor změníte, prohlížeč může stále používat starou verzi. Tzv cache busting řeší tento problém přidáním parametru jako ?v=1699123456, který se změní při každé úpravě souboru a nutí prohlížeč stáhnout novou verzi. Verzování lze vypnout v konfiguraci.

Trošku si zakonfigurujeme

Preferujete jiný název složky než assets? V takovém případě musíme přidat konfiguraci do common.neon, nicméně stačí pouhé tři řádky:

assets:
	mapping:
		default: static  # soubory v /www/static/

Někdy dávám assety na samostatnou subdoménu, jako třeba tady na webu Nette. Konfigurace pak vypadá takto:

assets:
	mapping:
		default:
			path: %rootDir%/www.files     # soubory v /www.files/
			url: https://files.nette.org

Na blogu jsem chtěl mít pořádek a rozdělit si assety logicky do tří kategorií: design webu (logo, ikony, styly), obrázky v článcích a namluvené verze článků. Každá kategorie má svou vlastní složku:

assets:
	mapping:
		default: assets       # běžné soubory /www/assets/
		content: media/images # obrázky článků v /www/media/images/
		voice: media/audio    # audio soubory v /www/media/audio/

Pro přístup k různým kategoriím používáme dvojtečkovou notaci kategorie:soubor. Pokud kategorii nespecifikujete, použije se výchozí (default). Takže v šabloně pak mám:

{* Obrázky k článku *}
<img n:asset="content:hero-photo.jpg" alt="Hero image">

{* Audio verze článku *}
<audio n:asset="voice:123.mp3" controls></audio>

V reálných šablonách pochopitelně pracujeme s dynamickými daty. Článek máte obvykle v proměnné, třeba $post, takže nemohu napsat voice:123.mp3, prostě napíšu:

<audio n:asset="voice:{$post->id}.mp3" controls></audio>

Abych nemusel v šabloně přemýšlet nad audio formátem, můžu do konfigurace přidat automatické doplňování koncovky:

assets:
	mapping:
		voice:
			path: media/audio
			extension: mp3  # automaticky doplní .mp3

Pak stačí psát:

<audio n:asset="voice:{$post->id}" controls></audio>

Když později začnu používat kvalitnější formát M4A, bude stačit jen upravit konfiguraci. A protože nemá smysl převádět staré nahrávky, zadám pole možných koncovek – systém je zkusí v daném pořadí a použije první, pro kterou existuje soubor:

assets:
	mapping:
		audio:
			path: media/audio
			extension: [m4a, mp3]  # zkusí nejdřív M4A, pak MP3

A co když soubor prostě není?

Nojo, ne každý článek má namluvenou verzi. Musím ověřit, jestli soubor vůbec existuje, a pokud ne, tak element <audio> nevykreslovat.

Tohle celé vyřeší jediný znak! Otazník v názvu atributu n:asset?

{* Zobrazí audio player jen pokud existuje namluvená verze *}
<audio n:asset?="voice:{$post->id}" controls></audio>

Stejně tak existuje otázníková varianta značky {asset?}. A pokud potřebujete asset obalit do vlastní HTML struktury nebo přidat podmíněnou logiku vykreslování, můžete použít funkci asset() nebo její „otazníkovou“ variantu tryAsset():

{var $voice = tryAsset("voice:{$post->id}")}
<div n:if="$voice" class="voice-version">
	<p>🎧 Tento článek si můžete také poslechnout</p>
	<audio n:asset=$voice controls></audio>
</div>

Funkce tryAsset() vrátí objekt assetu nebo null, pokud soubor neexistuje. Funkce asset() vrátí vždy objekt assetu nebo vyhodí výjimku. Dají se tak s nimi dělat různé kejkle, užitečný je třeba tento trik s fallbackem, když chcete zobrazit náhradní obrázek:

{var $avatar = tryAsset("avatar:{$user->id}") ?? asset('avatar:default')}
<img n:asset=$avatar alt="Avatar">

A co vlastně za objekty tyto funkce vlastně vrací? O nich si teď povíme.

Práce s assety v PHP kódu

S assety se pracuje nejen v šablonách, ale i v PHP kódu. K přístupu k nim slouží třída Registry, která je hlavním rozhraním celé knihovny. Tuto službu si necháme předat pomocí dependency injection:

class ArticlePresenter extends Presenter
{
	public function __construct(
		private Nette\Assets\Registry $assets
	) {}
}

Registry poskytuje metody getAsset() a tryGetAsset() pro získání asset objektů. Každý objekt reprezentuje nějaký resource. Nemusí jít o fyzický soubor – může být třeba dynamicky generovaný. Asset nese informace o URL a metadatech. V namespace Nette\Assets najdete tyto třídy:

  • ImageAsset – obrázky (width, height)
  • ScriptAsset – JavaScript soubory (type pro moduly)
  • StyleAsset – CSS soubory (media queries)
  • AudioAsset / VideoAsset – média (duration, dimensions)
  • FontAsset – fonty (správné CORS hlavičky)
  • EntryAsset – entry pointy pro Vite
  • GenericAsset – ostatní soubory (základní URL a metadata)

Každý asset má readonly vlastnosti specifické pro svůj typ:

// ImageAsset pro obrázky
$image = $this->assets->getAsset('photo.jpg');
echo $image->url;      // '/assets/photo.jpg?v=1699123456'
echo $image->width;    // 1920
echo $image->height;   // 1080
echo $image->mimeType; // 'image/jpeg'

// AudioAsset pro audio soubory
$audio = $this->assets->getAsset('song.mp3');
echo $audio->duration;  // délka v sekundách

// ScriptAsset pro JavaScript
$script = $this->assets->getAsset('app.js');
echo $script->type;     // 'module' nebo null

Vlastnosti jako rozměry obrázků nebo délka audio souborů se načítají lazy (až když je poprvé použijete), takže systém zůstává rychlý i s tisíci soubory.

First-class Vite integrace

Nette Assets mají nativní podporu pro Vite. Což je moderní nástroj, který řeší nutnost kompilovat (buildovat) frontend kód, protože:

  • se píše v jiných jazycích (TypeScript, Sass)
  • transpiluje se pro starší prohlížeče
  • spojuje se do jednoho souboru
  • minifikuje se pro menší velikost

Aby nebylo potřeba ve vývojovém režimu při každé změně celý proces opakovat, servíruje Vite soubory přímo bez spojování a minifikace. Každá změna se tak projeví okamžitě. A jako bonus to dokáže dokonce bez refreshování stránky – upravíte CSS nebo JavaScript a změna se projeví v prohlížeči během milisekund, aniž byste ztratili data ve formulářích nebo stav aplikace. Tomu se říká Hot Module Replacement.

Pro produkci pak Vite vytvoří klasické optimalizované buildy – spojí, zminifikuje, rozdělí do chunks a přidá verzované názvy pro cache busting.

V současném světě JavaScriptu je Vite de facto standard pro moderní frontend vývoj. Proto mi dávalo smysl zahrnout podporu přímo do Nette Assets. Díky otevřené architektuře si ale můžete snadno dopsat mapper pro jakýkoliv jiný bundler.

Možná si říkáte: „Vite adaptéry pro Nette už existují, v čem je tohle jiné?“ Ano, existují, a děkuji za to jejich autorům. Rozdíl je v tom, že Nette Assets je celý systém pro správu všech statických souborů, a Vite je jednou ze součástí, ale velmi elegantně integrovanou.

Do konfigurace common.neon stačí přidat jen dvě slova: type: vite.

assets:
	mapping:
		default:
			type: vite
			path: assets

Vite je známé tím, že funguje i bez konfigurace nebo se velmi jednoduše konfiguruje. To ale není úplně pravda, když chcete Vite integrovat s backendem – tam se konfigurace stává složitější. Proto jsem vytvořil @nette/vite-plugin, který celou integraci dělá zase úplně jednoduchou.

V konfiguračním souboru vite.config.ts stačí aktivovat plugin a uvést cestu ke vstupním bodům:

import { defineConfig } from 'vite';
import nette from '@nette/vite-plugin';

export default defineConfig({
	plugins: [
		nette({
			entry: 'app.js',  // vstupní bod
		}),
	],
});

A v šabloně layoutu vstupní bod načteme:

<!doctype html>
<head>
	{asset 'app.js'}
</head>

A to je vše! Systém se automaticky postará o ostatní:

  • V development módu načítá soubory z Vite dev serveru s Hot Module Replacement
  • V produkci používá zkompilované, optimalizované soubory

Žádná konfigurace, žádné podmínky v šablonách. Prostě to funguje.

Síla vlastních mapperů: Reálné příklady

Mapper je komponenta, která ví, kde najít soubory a jak vytvořit URL pro jejich načtení. Můžete mít více mapperů pro různé účely – lokální soubory, CDN, cloud storage nebo build nástroje. Systém Nette Assets je kompletně otevřený pro vytváření vlastních mapperů pro cokoliv.

Mapper pro produktové obrázky

Na eshopu potřebujete obrázky produktů v různých velikostech. Většinou už máte službu, která se stará o resize a optimalizaci obrázků. Stačí kolem ní postavit mapper pro Nette Assets.

Metoda getAsset() vašeho mapperu musí vracet příslušný typ assetu. V našem případě ImageAsset. Pokud konstruktoru zadáme navíc parametr file s cestou k lokálnímu souboru, automaticky načte rozměry obrázku (width, height) a MIME typ. Pokud požadovaný soubor neexistuje, vyhodíme výjimku AssetNotFoundException:

class ProductImageMapper implements Mapper
{
	public function getAsset(string $reference, array $options = []): Asset
	{
		// $reference je ID produktu
		$product = $this->database->getProduct((int) $reference);
		if (!$product) {
			throw new Nette\Assets\AssetNotFoundException("Product $reference not found");
		}

		$size = $options['size'] ?? 'medium'; // small, medium, large

		// Využijeme existující službu pro práci s obrázky
		return new Nette\Assets\ImageAsset(
			url: $this->imageService->getProductUrl($product, $size),
			file: $this->imageService->getProductFile($product, $size)
		);
	}
}

Registrace v konfiguraci:

assets:
	mapping:
		product: App\ProductImageMapper()

Použití v šablonách:

{* Různé velikosti produktu *}
<img n:asset="product:{$product->id}, size: small" alt="Product thumbnail">
<img n:asset="product:{$product->id}, size: large" alt="Product detail">

Automatické generování OG images

OG (Open Graph) obrázky se zobrazují při sdílení na sociálních sítích. Místo ručního vytváření můžete nechat systém generovat je automaticky podle typu obsahu.

V tomto případě parametr $reference určuje kontext obrázku. Pro statické stránky (např. homepage, about) se použijí předpřipravené soubory. Pro články se obrázek generuje dynamicky na základě názvu článku. Mapper nejprve zkontroluje cache – pokud už obrázek existuje, vrátí jej. Jinak jej vygeneruje a uloží do cache pro příští použití:

class OgImageMapper implements Mapper
{
	public function getAsset(string $reference, array $options = []): Asset
	{
		// Pro články generujeme dynamicky podle názvu
		if ($reference === 'article') {
			$title = $options['title'] ?? throw new LogicException('Missing option title for article');
			$filename = '/generated/' . md5("article-{$title}") . '.png';
			$path = $this->staticDir . $filename;
			if (!file_exists($path)) {
				file_put_contents($path, $this->ogGenerator->createArticleImage($title));
			}

		} else { // Pro statické stránky použijeme předpřipravené soubory
			$filename = "/{$reference}.png";
			$path = $this->staticDir . $filename;
			if (!file_exists($path)) {
				throw new Nette\Assets\AssetNotFoundException("Static OG image '$reference' not found");
			}
		}

		return new Nette\Assets\ImageAsset($this->baseUrl . $filename, file: $path);
	}
}

V šabloně pak přidáme do hlavičky:

{* Pro statickou stránku *}
{var $ogImage = asset('og:homepage')}

{* Pro článek s dynamickým generováním *}
{var $ogImage = asset('og:article', title: $article->title)}
<meta property="og:image" content={$ogImage}>
<meta property="og:image:width" content={$ogImage->width}>
<meta property="og:image:height" content={$ogImage->height}>

Díky tomuto přístupu máte automaticky vygenerované OG obrázky pro každý článek, které se vytvoří jen jednou a pak se cachují pro další použití.

Nette Assets: elegantnější správa assetů

Nette Assets představují elegantní systém, který se stará téměř o vše automaticky.

  • Eliminuje boilerplate kód – žádné více ručního konstruování URL
  • Podporuje moderní workflow – Vite, TypeScript, code splitting
  • Je flexibilní – vlastní mappery, různé storage systémy
  • Funguje hned – žádná složitá konfigurace

Kompletní popis najdete v dokumentaci a kód na GitHubu.

Už nikdy nebudete psát <img src="/images/photo.jpg?v=123456"> ručně!

David Grudl Je specialista na umělou inteligenci a webové technologie, tvůrce Nette Framework a dalších populárních open-source projektů. Publikuje na blozích Uměligence, phpFashion a La Trine. Školí práci s AI nástroji a moderuje pořad Tech Guys. Umělou inteligenci se snaží přiblížit lidem srozumitelným způsobem. Je kreativní a má smysl pro praktické využití technologií.

Komentáře

  1. Super. To tam chybelo jako sul, diky.

    před 13 hodinami
  2. Jeste to ma mouchy u vite dev to nacte build ne a musel jsem prepsat kompletne celej vite.config
    import { defineConfig } from ‚vite‘;
    export default defineConfig({
    build: {
    manifest: true,
    outDir: ‚./www/assets‘,
    emptyOutDir: true,
    rollupOptions: {
    input: {
    main: ‚/assets/main.js‘,
    },
    output: {
    entryFileNames: ‚[name]-[hash].js‘,
    chunkFileNames: ‚[name]-[hash].js‘,
    assetFileNames: ‚[name]-[hash][extname]‘,
    }
    },
    },
    })
    protoze ten config cos tam mel me vyhazoval chybu kvuli index.html
    navic to kopirovalo assety do ../www

    před 13 hodinami
    1. šlo by to přidat do nette/nette package?
    2. šlo by přidat macro {asset} do latte phpstorm pluginu?
    3. šlo by normalizovat cestu aby nedávala 2× lomítko pokud ho mám v cestě na začátku? jde to fixnout případně takhle no {asset trim($url, ‚/‘)} jinak mi to generuje /…ipts/main.js
    před 11 hodinami
  3. Velmi podobnou službu dělal https://github.com/…istry/images . Ten se tedy především soustředil na výrobu miniatur a obsluhu formulářů a k tomu mimochodem dělal i tohle mapování souborů – pomocí n:img=„prostor, id“ .

    Ale tenhle Nette Assets je mnohem dál. Díky

    před 9 hodinami

Chcete-li odeslat komentář, přihlaste se