sayamaapps / kirby-annai
Search and AI-agent discovery for Kirby 5 sites: robots.txt, sitemap, llms.txt, JSON-LD, meta tags.
Package info
github.com/sayamaapps/kirby-annai
Type:kirby-plugin
pkg:composer/sayamaapps/kirby-annai
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- getkirby/composer-installer: ^1.2
- giggsey/libphonenumber-for-php-lite: ^9.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.54
- getkirby/cms: ^5.0
- icanhazstring/composer-unused: ^0.9.7
- maglnet/composer-require-checker: ^4.0
- phpmd/phpmd: ^2.15
- phpunit/phpunit: 11.5.55
- vimeo/psalm: ^6.20
Suggests
- sayamaapps/kirby-koyomi: Events appear in sitemap.xml and llms.txt when Koyomi is installed and active
- sayamaapps/kirby-noren: Opening hours in JSON-LD and llms.txt come from Noren's schema.org arrays when it is installed and active
- sayamaapps/kirby-sudare: While Sudare's preview protection is on, every page is noindex and robots.txt says Disallow: /
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 17:59:04 UTC
README
Search-engine and AI-agent output for Kirby CMS 5: robots.txt, sitemap.xml, llms.txt, JSON-LD and meta tags.
Versions: Kirby ^5.0 · PHP 8.2 to 8.5 · package 2.x. The changes of each release are in CHANGELOG.md; what is planned for the next major version is in Planned for 3.0.
Annai builds these from the site's own content, so they cannot drift from the pages. The files are served at runtime and name the current domain. The plugin has no styling and processes no visitor data.
Features
- JSON-LD business node from the site's fields and Noren's opening hours.
- AI agents: user and search agents stay allowed; training crawlers are your choice.
- Upcoming Koyomi events in
llms.txt. - Respects Sudare preview protection.
annai-checkshows what engines and agents see, page by page.
Requirements
- PHP 8.2 or newer with
ext-mbstringandext-json - Kirby 5 (
^5.0), the site at the host root - Multilingual sites with language prefixes on one domain are supported. Languages on their own domains in one installation are not.
- Optional: Noren, Koyomi and Sudare, detected and never required. With Noren, set
noren.locations(also whensayamaapps.annai.locationis set), or Noren walks the whole page index on every page view that shows hours.
Installation
With Composer (recommended), from the site's root:
composer require sayamaapps/kirby-annai
Composer installs to site/plugins/annai/. Add that path to .gitignore and deploy it like vendor/.
Without Composer, download the release ZIP from GitHub, unpack it to site/plugins/annai/. Or add the repository as a Git submodule:
git submodule add https://github.com/sayamaapps/kirby-annai.git site/plugins/annai
Without Composer's giggsey/libphonenumber-for-php-lite, Annai publishes no phone numbers (the check reports it). Everything else works.
Usage
// site/config/config.php return [ 'sayamaapps.annai.active' => true, // off until true 'sayamaapps.annai.language' => 'de', // texts of llms.txt, English by default 'sayamaapps.annai.type' => 'GroceryStore', // schema.org type, LocalBusiness by default ];
A German site without a Kirby locale needs sayamaapps.annai.language, or llms.txt says "Contact".
In the layout's <head>, replace the hand-written meta tags with:
<?php snippet('annai/head') ?>
It prints the title, meta description, robots, canonical, hreflang, Open Graph and JSON-LD. Use snippet('annai/head', ['title' => false]) if the site prints its own title. To change the output, copy the snippet to site/snippets/annai/head.php.
Content fields. Annai reads these from the site, or from the business page (sayamaapps.annai.location, Noren's location, else the first betrieb page; an empty field falls back to the site): marke (brand), zusatz (tagline), beschreibung (description), einleitung (intro), strasse, plz, ort, telefon, fax, email, and profiles (instagram, facebook, added to sameAs). Rename them with sayamaapps.annai.fields.
A phone field holds only the number, such as 030 / 34567000, which is published as E.164 (+493034567000). A label or note in it publishes nothing, and the check reports it.
Hooks. Closure options (see Options) can replace Annai's values. A throwing hook is logged and Annai's own value is used; a throwing exclude.pages hook excludes the page. For example:
'sayamaapps.annai.exclude.pages' => fn (Kirby\Cms\Page $page): bool => $page->template()->name() === 'intern',
robots.txt and AI agents
sayamaapps.annai.robots.ai is a preset: null (default, no AI rules), 'blockTraining' (training crawlers get Disallow: /) or 'allowAll'. Presets never block user and search agents. Override single agents with sayamaapps.annai.robots.agents. The known agents and their roles are in docs/reference.md.
A / in sayamaapps.annai.disallow closes the site for everyone.
Sitemap, llms.txt and preview
/sitemap.xmllists indexable pages with<lastmod>, and each language version whensayamaapps.annai.hreflangis on. Above 50,000 URLs (sayamaapps.annai.sitemap.chunk) or 52,428,800 bytes it becomes a sitemap index. Each sitemap request builds all entries in memory: a multilingual site with more than about 40,000 URLs needsmemory_limit256 MB./llms.txtfollows the llmstxt.org format. Add own text withsayamaapps.annai.llms.extra. Routes and content types: docs/reference.md.- While Sudare protects the site, every page carries
noindex, nofollow, noarchive.sayamaapps.annai.noindex => truealso closes the files: robots.txt saysDisallow: /, the sitemap is empty, and llms.txt holds only its name. - Clear the page cache when a preview switches on and at go-live: cached pages are served before Annai runs.
Check tool
Run it before go-live. It reports each page, the robots, sitemap, llms.txt and phone findings, and makes no HTTP requests:
vendor/bin/annai-check --root=/path/to/site --url=https://example.test
Pass the production --url; options are --page=ID and --json. Exit codes: 0 no problems, 1 problems or robots warnings, 2 usage error or Annai off, 3 Kirby could not boot. The problem list is in docs/reference.md.
Upgrading
Coming from the predecessor plugin (its folder under site/plugins/), in one deploy: remove that plugin and any static robots.txt, sitemap.xml or llms.txt in public/, and set sayamaapps.annai.active. Replace snippet('strukturdaten') and the head lines of your layout with snippet('annai/head'). A live site keeps its business @id with 'sayamaapps.annai.entityId' => 'betrieb'. Renamed options: see the rename table.
Options
Every option is sayamaapps.annai.<key>, written flat: 'sayamaapps.annai.robots.ai' => 'blockTraining'. The nested form ('annai' => ['robots' => ['ai' => …]]) works for the 2.x name only (see Deprecations): Kirby resolves sayamaapps.annai.* through the plugin's own options block first, which shadows a nested sayamaapps array. null means unset. Broken values are logged and the default is used; with Kirby's debug option on, they throw.
The 2.x name annai.<key> still works as a fallback until 3.0, flat or nested, and is read without a warning. If both names are set, the new one wins.
| Option | Default | Meaning |
|---|---|---|
active |
false |
Master switch (only a real true) |
language |
site language | en or de for llms.txt |
fields |
German names | Rename content fields |
location |
null |
Business page id, or Closure(): ?Page |
locationTemplate |
'betrieb' |
Business page template, without location and Noren |
noren |
true |
Use Noren's opening hours |
koyomi |
true |
Use Koyomi's upcoming events |
type |
'LocalBusiness' |
schema.org type; false for none |
entityId |
'business' |
Business @id fragment |
area |
null |
Noren area with the main hours |
sameAs |
[] |
Profile URLs (http(s)), or Closure(): list |
geo |
null |
['lat' => …, 'lng' => …] |
action |
null |
['type', 'name', 'target'], or Closure(): ?array |
labels |
[] |
Own plugin texts by key |
image |
null |
Share image: path, URL, array, or Closure(Page) |
country |
'DE' |
addressCountry |
phoneCountry |
'49' |
Country code for national numbers |
title |
null |
Closure(Page, array $facts): string |
description |
null |
Closure(Page, array $facts): string |
descriptionLength |
155 |
Meta description length, 20 to 320 |
descriptionFallback |
false |
Fall back to page and site description when the intro is short |
canonical |
null |
Closure(Page): string |
locale |
null |
og:locale, e.g. de_DE |
twitterCard |
null |
'summary' or 'summary_large_image' |
noindex |
null |
null automatic (Sudare), true hides, false never |
noindexExcluded |
true |
noindex, follow for pages left out of the sitemap |
hreflang |
false |
hreflang links and sitemap alternates |
exclude.templates |
['error'] |
Templates left out of sitemap, llms.txt and check |
exclude.pages |
[] |
Page ids with subpages, or Closure(Page): bool |
disallow |
['/panel'] |
Disallow: paths in the * group; / closes the site |
robots.ai |
null |
AI preset: 'blockTraining' or 'allowAll' |
robots.agents |
[] |
Per-agent overrides, e.g. ['GPTBot' => false] |
robots.extra |
[] |
Raw robots.txt lines |
sitemap.lastmod |
true |
Emit <lastmod> |
sitemap.chunk |
50000 |
URLs per sitemap file, 1 to 50000 |
sitemap.extra |
null |
Extra URLs, or Closure(): list |
jsonld |
null |
Closure(Page, array $nodes, array $facts): array |
llms.optional |
[] |
Templates listed under "Optional" |
llms.extra |
null |
Closure(array $model, array $facts): array |
timezone |
'Europe/Berlin' |
Time zone of <lastmod> offsets |
Annai keeps its own string table (translations/) instead of Kirby's translations: sayamaapps.annai.labels overrides texts per request, {name} placeholders are filled in, and the language follows sayamaapps.annai.language, not the Panel language. A missing Kirby translation cannot turn a German llms.txt English.
Deprecations
annai.<option> in config.php (2.x) |
sayamaapps.annai.<option> |
Still read as fallback. Rename the keys in your site config. |
|---|---|---|
annai_option(), annai_active() |
Options::get(), Options::active() |
Still work, silent. |
Both 2.x spellings stay readable until 3.0.
Planned for 3.0
Not in 2.x: each item changes what a site sees, how it is configured or what it catches. Until 3.0 the 2.x names and behaviour stay as they are.
| Planned for 3.0 | In 2.x | Note |
|---|---|---|
Option errors in debug mode throw Kirby's Kirby\Exception\InvalidArgumentException |
\InvalidArgumentException |
A catch (\InvalidArgumentException) stops catching them |
Deprecation reports (Kirby's deprecated()) for annai.<key>, annai_option() and annai_active() |
Silent | In debug mode Kirby turns each report into an exception |
The classes live in the namespace Sayamaapps\Annai\. The names Annai\Text, Annai\Pages and so on are aliases in legacy.php and stay until 3.0.
Privacy
Annai sets no cookies, uses no browser storage and makes no request to third parties. robots.txt, sitemap.xml and llms.txt are public: put nothing into disallow, robots.extra or sitemap.extra that you would not publish.
Tests: composer test. Issues and pull requests are welcome, without a support promise. Report security problems privately through GitHub's vulnerability reporting. Changes: CHANGELOG.md.
License
MIT, see LICENSE.