Search by

sayamaapps / kirby-annai

simsimmal

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

Statistics

Installs: 60

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.2.3 2026-10-26 06:27 UTC

This package is auto-updated.

Last update: 2026-10-26 06:29:06 UTC


README

Search-engine and AI-agent output for Kirby CMS 5: robots.txt, sitemap.xml, llms.txt, JSON-LD and meta tags.

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-check shows what engines and agents see, page by page.

Requirements

  • PHP 8.2 or newer with ext-mbstring and ext-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 when annai.location is set), or Noren walks the whole page index on every page view that shows hours.

Installation

composer require sayamaapps/kirby-annai

Composer installs to site/plugins/annai/. Add that path to .gitignore and deploy it like vendor/. Without Composer, no phone numbers are published.

Usage

// site/config/config.php
return [
    'annai.active'   => true,           // off until true
    'annai.language' => 'de',           // texts of llms.txt, English by default
    'annai.type'     => 'GroceryStore', // schema.org type, LocalBusiness by default
];

A German site without a Kirby locale needs 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 (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 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:

'annai.exclude.pages' => fn (Kirby\Cms\Page $page): bool => $page->template()->name() === 'intern',

robots.txt and AI agents

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 annai.robots.agents. The known agents and their roles are in docs/reference.md.

A / in annai.disallow closes the site for everyone.

Sitemap, llms.txt and preview

  • /sitemap.xml lists indexable pages with <lastmod>, and each language version when annai.hreflang is on. Above 50,000 URLs (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 needs memory_limit 256 MB.
  • /llms.txt follows the llmstxt.org format. Add own text with annai.llms.extra. Routes and content types: docs/reference.md.
  • While Sudare protects the site, every page carries noindex, nofollow, noarchive. annai.noindex => true also closes the files: robots.txt says Disallow: /, 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 site/plugins/suchmaschinen, in one deploy: remove that plugin and any static robots.txt, sitemap.xml or llms.txt in public/, and set annai.active. Replace snippet('strukturdaten') and the head lines of your layout with snippet('annai/head'). A live site keeps its business @id with 'annai.entityId' => 'betrieb'. Renamed options: see the rename table.

Options

Write options flat ('annai.robots.ai') or nested under 'annai'. null means unset. Broken values are logged and the default is used; with Kirby's debug option on, they throw.

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

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.