medienbaecker / kirby-alter
Package info
github.com/medienbaecker/kirby-alter
Type:kirby-plugin
pkg:composer/medienbaecker/kirby-alter
Requires
- php: ^8.2
- getkirby/composer-installer: ^1.2
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Edit, generate and review alt texts for images in the Kirby Panel.
Installation
composer require medienbaecker/kirby-alter
Requires Kirby 5+ and PHP 8.2+.
If you don’t use Composer, download this repository and copy it to site/plugins/kirby-alter.
Quick start
Alter appears in the Panel menu automatically. Open it to write, review and save the alt text of all images of your site. Optionally, an LLM of your choice can draft them for you, see Generation.
Tip
If your config overrides panel.menu, add alter to it.
Panel
The toolbar filters the list (you can add your own filters), counts the saved alt texts, switches the language and saves or discards everything at once. With generation allowed, it also holds the Generate button for the current list.
Every image has a card with its alt text field, a character counter and buttons to save or discard that one change. Set maxLength to show a limit in the counter, flag longer texts as invalid and ask the model to stay under it.
Generation buttons
Enable panel.generation to allow generation in the Panel. A Generate button appears in the toolbar and on every image without alt text.
In the dropdown you choose whether to generate alt texts for the currently selected language or for all site languages at once. The image is sent to the provider once, for the default language. Other languages are translated from that text, so the image is not uploaded again. Panel generation never overwrites existing alt texts. It only fills missing ones as drafts.
Decorative images
Enable panel.decorative to add a "Doesn't need alt text" checkbox to each image.
The checkbox marks the image as reviewed even when the alt text is empty. Use it only for purely decorative images, which need an empty alt="". Most images carry meaning, and an empty alt hides them from screen reader users, so the W3C decision tree and its page on decorative images are worth a read before enabling this feature. Decorative images then count towards the progress badge and leave the Missing filter. The flag is stored per language in an alt_decorative field.
Generation
Generation is optional. It drafts alt texts in the Panel once you enable panel.generation, and on the command line with kirby alter:generate. Both need a provider and work with JPEG, PNG, GIF and WebP images.
Providers
api.provider is anthropic, openai, or custom for any OpenAI-compatible chat completions endpoint.
// Anthropic 'api' => [ 'provider' => 'anthropic', 'key' => 'your-api-key', 'model' => 'claude-sonnet-5', // default ], // OpenAI 'api' => [ 'provider' => 'openai', 'key' => 'your-api-key', 'model' => 'gpt-5.6-luna', // default ], // Custom, url and model required 'api' => [ 'provider' => 'custom', 'url' => 'https://llm.aihosting.mittwald.de/v1', 'key' => 'your-api-key', 'model' => 'Ministral-3-14B-Instruct-2512', ],
| Provider | api.url |
Notes |
|---|---|---|
| Mittwald AI Hosting | https://llm.aihosting.mittwald.de/v1 |
Dedicated plans get their own hostname |
| Ollama | http://localhost:11434/v1 |
No API key needed |
| Mistral | https://api.mistral.ai/v1 |
|
| OpenRouter | https://openrouter.ai/api/v1 |
The providers documentation covers thinking mode, token limits and other provider quirks.
CLI
The kirby alter:generate CLI command generates alt texts with the configured provider. Generated texts are stored as unsaved changes. Review and publish them in the Panel.
kirby alter:generate
Arguments, examples and a sample run are in the CLI documentation.
Options
// site/config/config.php return [ 'medienbaecker.alter' => [ 'api' => [ 'provider' => 'anthropic', // anthropic, openai or custom 'key' => 'your-api-key', 'url' => null, // base URL, required for custom 'model' => null, // model id, required for custom 'options' => [], // extra request body fields ], 'templates' => null, // limit to file templates 'ignore' => null, // fn($file), true keeps the file 'filters' => null, // e.g. ['missing', 'published' => [...]] 'sortBy' => null, // e.g. 'date desc' 'prompt' => 'Custom prompt', // string or fn($file) 'maxLength' => false, // e.g. 125 'language' => 'English', // for single-language sites 'panel' => [ 'generation' => false, // generation buttons in the Panel 'decorative' => false, // "Doesn't need alt text" checkbox ], ] ];
Dotted keys such as 'api.key' are also accepted.
Prompt
The prompt option is a string or a callback that receives the file. The default is:
'prompt' => function ($file) { $prompt = 'You are an accessibility expert writing alt text. Write a concise, short description in one to three sentences. Start directly with the subject - NO introductory phrases like "image of", "shows", "displays", "depicts", "contains", "features" etc.'; if ($file->parent() instanceof \Kirby\Cms\Page) { $prompt .= ' The image is on a page called "' . $file->parent()->title() . '".'; } $prompt .= ' The site is called "' . $file->site()->title() . '".'; $prompt .= ' Return the alt text only, without any additional text or formatting.'; return $prompt; }
The "accessibility expert" framing asks for alt text rather than a caption, which pushes the model towards the purpose of an image instead of an inventory of everything in it. Screen readers already announce an image, so the prompt bans "image of" and its relatives. The page title and the site title are the only context the model gets, which is why generated texts stay drafts for a human to review. For what makes a good alt text, read the W3C images tutorial, WebAIM on alternative text and Axess Lab's alt text guide.
You can override this with your own string or callback:
// Simple string prompt 'prompt' => 'Describe this image concisely for accessibility purposes.' // Custom callback with different context 'prompt' => function($file) { return 'Describe this image. Context: "' . $file->page()->text()->excerpt(100) . '"'; }
Sorting
By default, the pages follow the order of your site tree. If you want your newest content first, for example on a blog, use the sortBy option. It works like the sortBy option in pages sections:
'sortBy' => 'date desc'
Pages without a date appear after all dated pages. Add more field/direction pairs to sort them too:
'sortBy' => 'date desc modified desc'
For full control, pass a function that receives and returns the pages collection:
'sortBy' => fn($pages) => $pages->sortBy( fn($page) => $page->date()->toDate() ?: $page->modified(), 'desc' )
Filters
Besides All images, the filter dropdown has three built-in entries:
| Key | Label | Shows images |
|---|---|---|
saved |
Saved | with an alt text, saved or not, or marked as decorative |
unsaved |
Unsaved | with changes that are not saved yet |
missing |
No alt text | without any alt text and not marked as decorative |
All three look at the language selected in the toolbar.
Use filters to choose the entries and add your own. Strings pick built-in filters, keyed arrays define custom ones, and the dropdown follows the order of the list:
'filters' => [ 'missing', 'unsaved', 'published' => [ 'label' => 'Published pages', 'filter' => fn($file) => $file->page()?->isPublished() === true, ], ],
Tip
Like panel.menu, the list replaces the defaults. To keep a built-in filter, list it.
A custom filter needs a label and a filter function. The function receives the file and returns true to keep it. The label is a string, a translation key or an array with one entry per Panel language, such as ['en' => 'Published pages', 'de' => 'Veröffentlichte Seiten']. The key appears in the URL, for example ?filter=published.
Used images
Which images a site actually shows depends on its templates. A template might use a files field, a block, a KirbyText tag or just $page->image(), and Alter can't know which. So "used" is up to you. This filter covers the common cases:
'used' => [ 'label' => 'Used images', 'filter' => function ($file) { static $references = null; if ($references === null) { $references = []; $languages = kirby()->languages()->codes() ?: [null]; foreach ([site(), ...site()->index(true)->values()] as $model) { foreach ($languages as $language) { $text = implode("\n", $model->content($language)->toArray()); preg_match_all('!file://\w+|[\w./-]+\.\w+!', $text, $matches); foreach (array_unique($matches[0]) as $reference) { $references[$reference] = true; $references[ltrim($model->id() . '/' . $reference, '/')] = true; } } if ($image = $model->image()) { $references[$image->id()] = true; } } } return isset($references[$file->id()]) || isset($references['file://' . $file->uuid()?->id()]); }, ],
It reads the content of the site and every page, including drafts, in every language, and collects everything that looks like a UUID (file://…), a filename (photo.jpg) or a path (other-page/photo.jpg). An image counts as used when:
- a files field, block or layout points to it, by UUID or by filename.
- a KirbyText tag shows it, such as
(image: photo.jpg)or(image: other-page/photo.jpg). - it is the first image of its page. Remove this part if your templates don't fall back to
$page->image(), or add what they do instead.
The filter function runs once per image, so the static variable keeps the scan to once per request. On a site with 1,000 pages and 1,800 images, the filter adds about 0.1 seconds to the Panel view.
Images that a template picks some other way, such as $page->images()->last(), aren't found. A filename that only appears in plain text on its own page counts as used.




