rllngr / kirby-visibilizer
All-in-one SEO + GEO plugin for Kirby 5. Meta tags, sitemap.xml, robots.txt, llms.txt and JSON-LD — classic and headless.
Package info
github.com/RLLNGR/kirby-visibilizer
Type:kirby-plugin
pkg:composer/rllngr/kirby-visibilizer
Requires
- php: >=8.2
- getkirby/composer-installer: ^1.0
Requires (Dev)
- getkirby/cms: ^5.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
All-in-one SEO + GEO plugin for Kirby 5. Free and open source.
Handles everything that makes a Kirby site visible — to search engines and to AI engines.
Features
- Meta tags — title, description, Open Graph, Twitter/X card, Discord
- Cascade — site-level defaults, page-level overrides, automatic fallbacks
- Sitemap —
sitemap.xmlauto-generated from all listed pages, with hreflang for multilingual sites - Robots —
robots.txtgenerated dynamically, noindex pages excluded from sitemap automatically - GEO —
llms.txt,llms-full.txtandllms-sitemap.xmlfor AI search engines (Perplexity, ChatGPT, Claude…) - JSON-LD — Schema.org structured data with automatic BreadcrumbList
- Headless ready — JSON endpoints for decoupled frontends
- Migration friendly — built-in fallback for sites migrating from kirby-seo
Requirements
- Kirby 5
- PHP 8.2+
- The
curlandgdextensions (link checks, site icons)
Installation
composer require rllngr/kirby-visibilizer
Composer asks once to allow getkirby/composer-installer, which puts the plugin in site/plugins/kirby-visibilizer. Without Composer, copy the folder there: it needs nothing else.
Quick start
Both kinds of site start the same way. Add a tab to the blueprints of the pages you want to manage:
# site/blueprints/tabs/seo.yml label: SEO icon: search columns: preview: width: 1/2 sections: seo_preview: extends: sections/seo-preview fields: width: 1/2 sections: seo: extends: sections/seo
# site/blueprints/pages/default.yml (and each page blueprint) tabs: content: ... seo: tabs/seo
A blueprint made of plain sections: can take the two sections directly. A page without these fields still gets its tags from the cascade, but the SEO view only lists pages that have them, as soon as one page does.
Then open /panel/seo?tab=settings for the site-wide defaults: home page title, default description and share image, default image alt text, site icon, llms.txt texts. Nothing to add to site.yml.
Classic site
Add the snippets inside your <head> and use the alt text in your templates:
<?php snippet('seo/head') ?> <?php snippet('seo/jsonld') ?>
<img src="<?= $image->url() ?>" alt="<?= $image->altText()->esc() ?>">
Remove your own <title>, description, Open Graph and favicon tags: seo/head writes them. sitemap.xml, robots.txt, llms.txt, llms-full.txt and the icons are served at the root. A static file of the same name in the root wins over the route, so delete any old robots.txt or favicon.ico.
Headless site
Tell the plugin where the public site is. Everything else is on the front end: see Headless mode.
// site/config/config.php 'rllngr.kirby-visibilizer' => [ 'mode' => 'headless', 'siteUrl' => env('FRONTEND_URL'), // the front end, not Kirby ],
- Query
seo: trueon each page and fill the<head>from it. - Query
altTextinstead ofalton images. A front end that readsfile.altgets none of the default texts, while the SEO view still counts them as described. - Serve
/sitemap.xml,/robots.txt,/llms.txtand the icons from the endpoints. - For the Panel's preview, let the Panel frame the front end:
frame-ancestors https://panel.example.comin its Content-Security-Policy, and noX-Frame-Options. The preview shows the published version unless the front end reads Kirby's_versionand_tokenparameters.
Check the install
/panel/seoopens and its cards show scores.- Classic: the page source has the
<title>,og:*and JSON-LD tags, and/sitemap.xmlanswers. - Headless:
/__seo__?page=<id>answers on Kirby, and canonical URLs use the front end's address. - The Settings tab has a section of config values: check the mode and address there.
- A page that is not
listedgetsnoindexand stays out of the sitemap: that is expected.
If the site sets panel.menu, add 'visibilizer' to the list or the SEO entry will not show.
On the Site page (optional)
Defaults can also be edited on the Site page, with the same field names:
# site.yml sections: seo: extends: sections/seo geo: extends: sections/geo
The seo-preview section shows a live preview panel with three tabs (Google SERP, OG/social card, Twitter/X card) and a checklist of SEO issues. It reads field values in real time — changes are reflected before saving.
Panel view — SEO
The Panel gets an SEO entry in its menu (/panel/seo), styled like the moniter Audience view (cream, ink bars, coloured cards; follows the Panel's light / dark theme).
- Cards — a score for titles, descriptions, alt texts and links, with optimal / acceptable / to-fix counts. Clicking a card opens the matching tab, filtered on what needs fixing.
- Score for other tools —
(new Audit(kirby()))->summary()returns these cards and a global score (titles 30 %, descriptions 30 %, alt texts 20 %, links 20 %, empty cards left out), with default values counted as in the view. kirby-moniter serves it to the Moniter dashboard. - Count default values — on (default): titles and descriptions taken from elsewhere (page title, a template field, the site) are judged on what they produce in
<head>. Off: anything not filled in the SEO tab is flagged. The choice is remembered in the browser and also applies to the live preview. - Pages — every page whose blueprint has the SEO fields: meta title and description as rendered in
<head>(with adefault/sitebadge when inherited), share image, indexing. Click a row to see its issues (length, duplicates, inherited values, missing image). - Images — alt text and weight of every image on those pages.
- Site icon — in the Settings tab, a square PNG (≥ 512 px,
icon_png), an optional SVG (icon_svg; an existingfaviconfield is read as a fallback), a short name and two colours. The plugin generatesfavicon.ico(16/32/48),favicon-96x96.png,favicon.svg,apple-touch-icon.png(180, on the background colour),web-app-manifest-192x192.png/-512x512.png, a maskable 512 icon andsite.webmanifest, serves them at the site root and writes the<link>/theme-colortags fromseo/head. A static file of the same name at the root wins over the plugin's route: the Settings tab flags it. The icon also shows in the Google, social and X previews. - Site files — the site's files with what uses each one (icon, share image, a page) or an unused badge, and an upload button.
- Settings — site-wide defaults, saved straight into the site content (no draft / publish step), in the Panel's current language; requires the permission to update the site. Below them, what is set in
config.php(fallbacks, alt templates, separator…) is shown read-only. - Links — links found in the content of every language (HTML, Markdown, KirbyText, link fields,
page:///file://UUIDs) with the pages they come from. Internal links are resolved by Kirby directly (no HTTP request); external links are checked from the server in small batches and cached for a week. Recheck all forces a new check. A redirect that only changes the protocol,wwwor a trailing slash counts as OK. Onlyhttpandhttpsare requested, never an address on the internal network, so the content cannot be used to probe the server. Sites that block robots (LinkedIn, some CDNs) are marked to check by hand.
'rllngr.kirby-visibilizer' => [ 'panel' => [ 'access' => true, // false to hide it, or a list of roles: ['admin', 'editor'] 'altField' => 'alt', // file field holding the alt text (see "Image alt texts") 'exclude' => [ 'templates' => [], 'pages' => [], ], ], ],
Labels in French and English. The Panel code lives in src/panel.js (ordinary template strings); index.js is generated from it with precompiled render functions, so it also works with 'panel' => ['vue' => ['compiler' => false]]:
npm install npm run build # scripts/build-panel.mjs → index.js, commit both composer install && composer test # PHP tests (links, addresses, fallbacks, alt texts)
Configuration
All options go in config/config.php under the rllngr.kirby-visibilizer key.
return [ 'rllngr.kirby-visibilizer' => [ 'mode' => 'classic', // 'classic' or 'headless' 'cache' => true, 'cacheDuration' => 60, // minutes 'titleSeparator' => ' — ', 'sitemap' => [ 'enabled' => true, 'exclude' => [ 'templates' => ['error'], 'pages' => [], ], ], 'robots' => [ 'enabled' => true, 'disallow' => ['/panel/', '/api/'], 'sitemap' => true, ], 'llms' => [ 'enabled' => true, 'sitemap' => true, 'sections' => [], ], ], ];
Panel fields
The plugin reads the following fields from your content files. All are optional — the cascade fills in sensible defaults when a field is empty.
| Field | Type | Used for |
|---|---|---|
seo_title |
text | <title>, og:title fallback |
seo_description |
textarea | <meta name="description">, og:description fallback |
seo_robots |
select | <meta name="robots"> |
seo_canonical |
url | <link rel="canonical"> |
og_image |
files (max: 1) | og:image, twitter:image fallback |
og_title |
text | og:title override |
og_description |
textarea | og:description override |
twitter_card |
select | twitter:card |
twitter_image |
files (max: 1) | twitter:image override |
llmsdescription |
textarea | /llms.txt site summary |
llmsinstructions |
textarea | /llms.txt AI instructions |
Site-level fields (in site.yml) follow the same names and serve as defaults for all pages.
Field fallbacks
When seo_title / seo_description / og_image are empty, a page can borrow one of its own fields, per template ('*' = any template). Text values are turned into plain text and cut at length characters (60 for titles); image fields use their first image.
'rllngr.kirby-visibilizer' => [ 'fallbacks' => [ 'description' => [ 'project' => 'introduction', '*' => ['intro', 'text'], // first non-empty field wins ], 'image' => ['project' => 'cover'], 'title' => [], 'length' => 160, ], ],
Cascade: SEO field → template field → page title (titles) / site description (descriptions) / site share image, then the page's first image (images).
The shared image is resized to ogImageWidth (default 1200, null for the original file) and og:image:width / og:image:height are written; its og:image:alt uses the image's alt text (see below). The live preview and the SEO view show where each value comes from.
Image alt texts
When an image's alt field is empty, an alt text can be built from a template using Kirby queries: the parent page (page), the file (file), the site (site). Empty values are dropped along with their separators.
'rllngr.kirby-visibilizer' => [ 'alt' => [ 'template' => '{{ page.title }} — {{ site.title }}', 'templates' => [ // per parent page template, 'site' for site files 'project' => '{{ page.title }}, {{ page.city }} ({{ page.country }}) — photo {{ file.credit }} · {{ site.title }}', ], ], ],
Use it in your templates with $image->altText(): the alt field if filled in, otherwise the generated text (a regular field: ->esc(), ->or()…).
<img src="<?= $image->url() ?>" alt="<?= $image->altText()->esc() ?>">
Without a template in config.php, the Default image alt text field of the Settings tab is used instead. It is written from the Panel, so it only reads content fields ({{ page.client }}, {{ file.credit }}, {{ site.title }}), never Kirby queries: anything else in braces is dropped.
In the SEO view, generated texts are shown with an auto badge, and Fill empty texts writes them into the empty alt fields (existing texts are never overwritten) so editors can refine them.
GEO — llms.txt sections
By default, llms.txt only contains the site header (title, description, instructions). To populate it with page sections, configure them in config.php:
'llms' => [ 'sections' => [ [ 'heading' => 'Projects', 'pages' => 'site.find("projects").children.listed', 'itemDescription' => 'description', // field name ], [ 'heading' => 'Journal', 'pages' => 'site.find("journal").children.listed', 'limit' => 20, 'itemDescription' => 'summary', ], ], ],
Each section accepts:
| Key | Type | Description |
|---|---|---|
heading |
string | Section title (## Heading) |
pages |
string | callable | Kirby Query or fn($kirby) => $kirby->... |
itemTitle |
string | callable | Field name or closure for the item title |
itemDescription |
string | callable | Field name or closure for the short description |
itemContent |
string | callable | Field name for full content (used in llms-full.txt only) |
itemUrl |
callable | fn($page) => $page->url() — override the URL |
limit |
int | Maximum number of items |
single |
bool | Treat the result as a single page (not a collection) |
Headless mode
For a decoupled front end (Nuxt, Next, Astro…), set the mode and the public address of the front end:
'rllngr.kirby-visibilizer' => [ 'mode' => 'headless', 'siteUrl' => 'https://example.com', // the front end, not Kirby 'trailingSlash' => false, // match the front end's routes ],
siteUrl replaces Kirby's address in everything that is output: canonical, og:url, hreflang, JSON-LD, sitemap, robots.txt, llms.txt, and the page links of the Panel (preview, SEO view). A page can provide its own address with a frontendUrl() page method. Files (share images) keep Kirby's address, since Kirby serves them — proxy them on the front end if Kirby is not public.
The file routes (sitemap.xml, robots.txt, llms*.txt) are disabled and the data is exposed instead:
KQL
page.seo and site.seo return the whole computed <head>, and file.altText the alt text with its template fallback. They are allowed in KQL out of the box (@kql-allowed), so the SEO data comes with the page in a single query:
{ query: 'page("projects/moncler")', select: { title: true, seo: true, // title, description, og_title, og_description, og_image, og_image_alt, // og_image_size {width, height}, og_type, twitter_card, twitter_image, // robots, canonical, hreflang [{hreflang, url}], schema (JSON-LD) cover: { query: 'page.cover.toFile', select: { url: true, altText: true } }, }, }
Endpoints
| Endpoint | Returns |
|---|---|
/__seo__?page=projects/moncler |
{ result: seo } for the page — 404 with { result: null } if it does not exist or is a draft |
/__seo__ |
{ result: seo } for the site |
/__seo-sitemap__ |
{ result: [{ loc, lastmod, alternates: [{ hreflang, href }] }] } — published pages, exclusions and noindex removed |
/__icons__ |
{ result: { enabled, tags, manifest, files } } — the icon's <head> tags, the web manifest and the URL of every generated file (also site.icons in KQL) |
/__icons__/favicon.ico … |
the generated files themselves — serve them at the front end's root (/favicon.ico, /apple-touch-icon.png, /site.webmanifest…) |
/__robots__ |
{ result: { text } } — robots.txt to serve as is |
/__llms__ · /__llms-full__ |
llms.txt data as JSON |
/__llms-sitemap__ |
{ result: { xml } } |
These endpoints are public (it is public data), whatever the KQL authentication.
On the front end
- Fill the
<head>fromseo(e.g. Nuxt'suseSeoMeta/useHead), withschemain a<script type="application/ld+json">. - Serve
/sitemap.xml,/robots.txt,/llms.txt(and/llms-sitemap.xml) from server routes built on the endpoints above. - Use
altTextinstead ofaltin image queries. If the front end builds its own alt text, give the plugin the same template inconfig.php, so the SEO view judges what the site really says. - Write
site.icons.tagsin the<head>and proxy/favicon.ico,/favicon.svg,/favicon-96x96.png,/apple-touch-icon.png,/web-app-*.pngand/site.webmanifestto/__icons__/<name>. - In the Panel, everything works as in classic mode: SEO tabs, live preview, SEO view and its settings.
- With kirby-headless, a page's
frontendUrl()is picked up automatically: setheadless.panel.frontendUrlto the same address assiteUrl, and useoptions: { preview: "{{ page.frontendUrl }}" }in page blueprints.
JSON-LD customisation
Override or extend the default schema from any template:
<?php snippet('seo/jsonld', [ 'schema' => [ '@context' => 'https://schema.org', '@type' => 'Article', 'headline' => $page->title()->value(), 'author' => [ '@type' => 'Person', 'name' => 'Nicolas Rollinger', ], 'datePublished' => $page->date()->toDate('c'), ], ]) ?>
When $schema is passed, the default WebPage/BreadcrumbList schema is replaced entirely.
Migration
From kirby-llmsizer
No action required. The GEO panel fields (llmsdescription, llmsinstructions) and all config options under llms use identical names. Remove rllngr/kirby-llmsizer from your composer.json and add rllngr/kirby-visibilizer.
From kirby-seo
Enable the compatibility fallback in config.php:
'rllngr.kirby-visibilizer' => [ 'compat' => [ 'kirby-seo' => true, ], ],
This makes the meta cascade read the old kirby-seo field names (metaTitle, metaDescription, ogImage, twitterImage, twitterCardType, robots) as a fallback when the new fields are empty. Your existing content is read without any data migration.
When you're ready to clean up, copy the old field values to the new field names in your content files and disable the compat option.
License
MIT — Nicolas Rollinger