chore: update kirby-seo plugin to v2.0.0-alpha.12

Update plugin from v1.1.2 to v2.0.0-alpha.12 for Kirby 5 compatibility.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-10 16:23:23 +01:00
parent ff215de723
commit 04a14a7f1f
70 changed files with 6142 additions and 3 deletions

View file

@ -0,0 +1,145 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Toolkit\I18n;
use tobimori\Seo\Buttons\RobotsViewButton;
use tobimori\Seo\Buttons\UtmShareViewButton;
use tobimori\Seo\Dialogs\UtmShareDialog;
use tobimori\Seo\Seo;
return [
'seo' => fn () =>
[
'buttons' => [
'page.robots' => fn (Page $page) => new RobotsViewButton($page),
'utm-share' => fn (ModelWithContent $model) => new UtmShareViewButton($model)
],
'drawers' => [
'gsc-data' => [
'pattern' => 'seo/gsc/data/(:all)',
'load' => function (string $parent) {
$kirby = App::instance();
$request = $kirby->request();
$metric = $request->get('metric', 'clicks');
$asc = (bool) $request->get('asc', in_array($metric, ['position', 'query']) ? 1 : 0);
$page = max(1, (int) $request->get('page', 1));
$limit = max(1, min(100, (int) $request->get('limit', 20)));
try {
$model = Find::parent(ltrim($parent, '/'));
} catch (\Exception $e) {
return ['component' => 'k-error-drawer', 'props' => ['message' => 'Model not found']];
}
$gsc = Seo::option('components.gsc');
if (!$gsc::hasCredentials() || !$gsc::isConnected() || !$gsc::property()) {
return ['component' => 'k-error-drawer', 'props' => ['message' => 'GSC not connected']];
}
$title = I18n::translate('seo.sections.searchConsole.title');
if ($model instanceof Page) {
$title .= ' · ' . $model->title()->value();
}
$data = $gsc::queryForModel($model, $metric, 25000, $asc);
$total = count($data);
$pageData = array_slice($data, ($page - 1) * $limit, $limit);
// format numbers with locale
$locale = $kirby->panelLanguage();
$number = new NumberFormatter($locale, NumberFormatter::DECIMAL);
$percent = new NumberFormatter($locale, NumberFormatter::PERCENT);
$percent->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 1);
$percent->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, 1);
$decimal = new NumberFormatter($locale, NumberFormatter::DECIMAL);
$decimal->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 1);
$decimal->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, 1);
$rows = array_map(fn ($row) => [
'query' => $row['keys'][0],
'clicks' => $number->format($row['clicks']),
'impressions' => $number->format($row['impressions']),
'ctr' => $percent->format($row['ctr']),
'position' => $decimal->format($row['position'])
], $pageData);
return [
'component' => 'k-gsc-drawer',
'props' => [
'title' => $title,
'icon' => 'google',
'parent' => $parent,
'metric' => $metric,
'sortAsc' => $asc,
'page' => $page,
'limit' => $limit,
'total' => $total,
'columns' => [
'query' => ['label' => I18n::translate('seo.sections.searchConsole.query'), 'width' => '1/2', 'mobile' => true],
'clicks' => ['label' => I18n::translate('seo.sections.searchConsole.clicks'), 'width' => '1/8', 'align' => 'right'],
'impressions' => ['label' => I18n::translate('seo.sections.searchConsole.impressions'), 'width' => '1/8', 'align' => 'right'],
'ctr' => ['label' => I18n::translate('seo.sections.searchConsole.ctr'), 'width' => '1/8', 'align' => 'right'],
'position' => ['label' => I18n::translate('seo.sections.searchConsole.position'), 'width' => '1/8', 'align' => 'right', 'mobile' => true]
],
'rows' => $rows
]
];
}
]
],
'dialogs' => [
'utm-share' => [
'pattern' => 'seo/utm-share/(:all)',
'controller' => UtmShareDialog::class
],
'gsc-select-property' => [
'pattern' => 'seo/gsc/select-property',
'load' => function () {
$siteUrl = App::instance()->site()->url();
$gsc = Seo::option('components.gsc');
$properties = $gsc::listProperties();
$options = array_map(fn ($p) => [
'value' => $p['siteUrl'],
'text' => str_starts_with($p['siteUrl'], 'sc-domain:')
? substr($p['siteUrl'], 10) . ' (' . I18n::translate('seo.sections.searchConsole.scDomain') . ')'
: $p['siteUrl']
], $properties);
$currentProperty = $gsc::property();
$defaultProperty = $currentProperty ?? $gsc::findMatchingProperty($siteUrl);
return [
'component' => 'k-form-dialog',
'props' => [
'fields' => [
'property' => [
'label' => I18n::translate('seo.sections.searchConsole.selectPropertyLabel'),
'type' => 'select',
'required' => true,
'options' => $options,
'empty' => false
]
],
'submitButton' => I18n::translate('select'),
'value' => [
'property' => $defaultProperty
]
]
];
},
'submit' => function () {
$property = App::instance()->request()->get('property');
Seo::option('components.gsc')::setProperty($property);
return [
'event' => 'gsc.propertySelected'
];
}
]
]
]
];

View file

@ -0,0 +1,130 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Http\Response;
use tobimori\Seo\Ai;
use tobimori\Seo\Seo;
return [
'seo-writer' => [
'extends' => 'writer',
'props' => [
/**
* Enables/disables the character counter in the top right corner
*/
'ai' => function (string|bool $ai = false) {
if (!Seo::option('components.ai')::enabled()) {
return false;
}
// check ai permission @see index.php L31
if (App::instance()->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
return false;
}
return $ai;
},
// reset defaults
'counter' => fn (bool $counter = false) => $counter, // we have to disable the counter because its at the same place as our ai button
'inline' => fn (bool $inline = true) => $inline,
'marks' => fn (array|bool|null $marks = false) => $marks,
'nodes' => fn (array|bool|null $nodes = false) => $nodes,
],
'api' => fn () => [
[
'pattern' => 'ai/stream',
'method' => 'POST',
'action' => function () {
$kirby = $this->kirby();
$component = Seo::option('components.ai');
if (!$component::enabled()) {
return Response::json([
'status' => 'error',
'message' => t('seo.ai.error.disabled')
], 404);
}
if ($kirby->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
return Response::json([
'status' => 'error',
'message' => t('seo.ai.error.permission')
], 404);
}
$data = $kirby->request()->body()->data();
$lang = $kirby->api()->language();
// for site, use homepage
$model = $this->field()->model();
$page = $model instanceof Page ? $model : $model->homePage();
$kirby->site()->visit($page, $lang);
if ($lang) {
$kirby->setCurrentLanguage($lang);
}
// inject data in snippets / rendering process
$kirby->data = [ // TODO: check if we want to access the draft / edited version for $page
'page' => $page,
'site' => $kirby->site(),
'kirby' => $kirby
];
// begin streaming thingy
ignore_user_abort(true);
@set_time_limit(0);
while (ob_get_level() > 0) {
ob_end_flush();
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
echo ":ok\n\n";
flush();
$send = static function (array $event): void {
echo 'data: ' . json_encode(
$event,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
};
try {
foreach (
$component::streamTask($this->field()->ai(), [
'instructions' => $data['instructions'] ?? null,
'edit' => $data['edit'] ?? null
]) as $chunk
) {
$send([
'type' => $chunk->type,
'text' => $chunk->text,
'payload' => $chunk->payload,
]);
}
} catch (\Throwable $exception) {
$send([
'type' => 'error',
'payload' => [
'message' => $exception->getMessage(),
],
]);
}
exit();
}
]
]
]
];

View file

@ -0,0 +1,64 @@
<?php
use Kirby\Cms\Page;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use tobimori\Seo\Seo;
return [
'page.update:after' => function (Page $newPage, Page $oldPage) {
// only inject blueprint defaults if the seo tab is present
if ($newPage->blueprint()->tab('seo')) {
$updates = A::reduce(
$newPage->kirby()->option('tobimori.seo.robots.types'),
function ($carry, $robots) use ($newPage) {
$upper = Str::ucfirst($robots);
if ($newPage->content()->get("robots{$upper}")->value() === '') {
$carry["robots{$upper}"] = 'default';
}
return $carry;
},
[]
);
if (A::count($updates)) {
$newPage = $newPage->update($updates, $newPage->kirby()->languageCode());
}
}
if (Seo::option('indexnow.enabled')) {
$indexNow = new (Seo::option('components.indexnow'))($newPage);
$indexNow->request();
}
return $newPage;
},
'page.changeStatus:after' => function (Page $newPage, Page $oldPage) {
if (Seo::option('indexnow.enabled')) {
$indexNow = new (Seo::option('components.indexnow'))($newPage);
$indexNow->request();
}
},
'page.changeSlug:after' => function (Page $newPage, Page $oldPage) {
if (Seo::option('indexnow.enabled')) {
$indexNow = new (Seo::option('components.indexnow'))($newPage);
$indexNow->request();
}
},
'page.render:before' => function (string $contentType, array $data, Page $page) {
if (!class_exists('Spatie\SchemaOrg\Schema')) {
return;
}
if (option('tobimori.seo.generateSchema')) {
$page->schema('WebSite')
->url($page->metadata()->canonicalUrl())
->copyrightYear(date('Y'))
->description($page->metadata()->metaDescription())
->name($page->metadata()->metaTitle())
->headline($page->metadata()->title());
}
},
];

View file

@ -0,0 +1,112 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Data\Json;
use tobimori\Seo\Meta;
use tobimori\Seo\Seo;
use tobimori\Seo\Ai;
use tobimori\Seo\IndexNow;
use tobimori\Seo\SchemaSingleton;
use tobimori\Seo\GoogleSearchConsole;
return [
// if you want to extend some of the built-in classes, you can overwrite them using the components config option
// and page methods or similar stuff will adapt. full customizability!
'components' => [
'meta' => Meta::class,
'ai' => Ai::class,
'indexnow' => IndexNow::class,
'schema' => SchemaSingleton::class,
'gsc' => GoogleSearchConsole::class,
],
'cache.searchConsole' => true,
'cache.indexnow' => true,
'cascade' => [
'fields',
'programmatic',
// 'fallbackFields', // fallback to meta fields for open graph fields
'parent',
'site',
'options'
],
'default' => [ // default field values for metadata, format is [field => value]
'metaTitle' => fn (Page $page) => $page->title(),
'metaTemplate' => '{{ title }} - {{ site.title }}',
'ogTemplate' => '{{ title }}',
'ogSiteName' => fn (Page $page) => $page->site()->title(),
'ogType' => 'website',
'ogDescription' => fn (Page $page) => $page->metadata()->metaDescription(),
'cropOgImage' => true,
'locale' => fn (Page $page) => $page->kirby()->language()?->locale(LC_ALL) ?? Seo::option('locale', 'en_US'),
// default for robots: noIndex if global index configuration is set, otherwise fall back to page status
'robotsIndex' => function (Page $page) {
$index = Seo::option('robots.index');
if (!$index) {
return false;
}
return Seo::option('robots.followPageStatus') ? $page->isListed() : true;
},
'robotsFollow' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsArchive' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsImageindex' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsSnippet' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
],
'socialMedia' => [ // default fields for social media links, format is [field => placeholder]
'twitter' => 'https://twitter.com/my-company',
'facebook' => 'https://facebook.com/my-company',
'instagram' => 'https://instagram.com/my-company',
'youtube' => 'https://youtube.com/channel/my-company',
'linkedin' => 'https://linkedin.com/company/my-company',
'bluesky' => 'https://bsky.app/profile/example.bsky.social',
'mastodon' => 'https://mastodon.social/@example'
],
'previews' => [
'google',
'facebook',
'slack'
],
'robots' => [
'enabled' => true, // whether robots handling should be done by the plugin
// @deprecated - please use robots.enabled
'active' => fn () => Seo::option('sitemap.enabled'),
'followPageStatus' => true, // should unlisted pages be noindex by default?
'pageSettings' => true, // whether to have robots settings on each page
'index' => fn () => !App::instance()->option('debug'), // default site-wide robots setting
'sitemap' => null, // sets sitemap url, will be replaced by plugin sitemap in the future
'content' => [], // custom robots content
'types' => ['index', 'follow', 'archive', 'imageindex', 'snippet'] // available robots types
],
'sitemap' => [
'enabled' => true,
// @deprecated - please use sitemap.enabled
'active' => fn () => Seo::option('sitemap.enabled'),
'redirect' => true, // redirect /sitemap to /sitemap.xml
'locale' => 'en',
'generator' => require __DIR__ . '/options/sitemap.php',
'changefreq' => 'weekly',
'groupByTemplate' => false,
'excludeTemplates' => ['error'],
'priority' => fn (Page $page) => number_format(($page->isHomePage()) ? 1 : max(1 - 0.2 * $page->depth(), 0.2), 1),
],
'files' => [
'parent' => null,
'template' => null,
],
'canonical' => [
'base' => null, // base url for canonical links
'trailingSlash' => false, // whether to add trailing slashes to canonical URLs (except for files)
],
'ai' => require __DIR__ . '/options/ai.php',
'indexnow' => require __DIR__ . '/options/indexnow.php',
'searchConsole' => [
'enabled' => true,
'credentials' => null,
'tokenPath' => fn () => App::instance()->root('config') . '/.gsc-tokens.json'
],
'generateSchema' => true, // whether to generate default schema.org data
'locale' => 'en_US', // default locale, used for single-language sites
'dateFormat' => null, // custom date format
];

View file

@ -0,0 +1,32 @@
<?php
use tobimori\Seo\Ai\Drivers\Anthropic;
use tobimori\Seo\Ai\Drivers\OpenAi;
// TODO: custom provider per task
return [
'enabled' => true,
'provider' => 'openai',
'providers' => [
'openai' => [
'driver' => OpenAi::class,
'config' => [
'apiKey' => '', // needs to be defined
],
],
'anthropic' => [
'driver' => Anthropic::class,
'config' => [
'apiKey' => '', // needs to be defined
],
],
'openrouter' => [
'driver' => OpenAi::class,
'config' => [
'apiKey' => '', // needs to be defined
'model' => 'openai/gpt-5-nano',
'endpoint' => 'https://openrouter.ai/api/v1/responses',
],
],
],
];

View file

@ -0,0 +1,29 @@
<?php
return [
'enabled' => true,
'searchEngine' => 'https://api.indexnow.org', // one will propagate to all others. so this is fine @see https://www.indexnow.org/faq
'rules' => [
// by default, only the current page is requested to be indexed (if indexable: robots allow + listed status)
// however you might want to index other pages as well. for example, the 'blog overview' page should always be reindexed when a new 'blog post' is indexed
//
// syntax: 'match pattern' => ['invalidation rules']
//
// match patterns:
// - '/blog/*' - url pattern (glob or regex)
// - 'article' - template name
// - '*' - wildcard, matches all pages
//
// invalidation rules:
// - 'parent' => true (direct parent) or number (levels up)
// - 'children' => true (all descendants) or number (depth limit)
// - 'siblings' => true (all siblings at same level)
// - 'urls' => ['/shop', '/'] (specific urls to invalidate)
// - 'templates' => ['category', 'shop'] (invalidate all pages with these templates)
//
// examples:
// '/blog/*' => ['parent' => true],
// 'article' => ['parent' => 2, 'urls' => ['/blog', '/']],
// 'product' => ['parent' => true, 'siblings' => true, 'templates' => ['category']],
],
];

View file

@ -0,0 +1,55 @@
<?php
use tobimori\Seo\Sitemap\SitemapIndex;
use tobimori\Seo\Meta;
return function (SitemapIndex $sitemap) {
$exclude = option('tobimori.seo.sitemap.excludeTemplates', []);
$pages = site()->index()->filter(fn ($page) => $page->metadata()->robotsIndex()->toBool() && !in_array($page->intendedTemplate()->name(), $exclude));
if ($group = option('tobimori.seo.sitemap.groupByTemplate')) {
$pages = $pages->group('intendedTemplate');
}
if (is_a($pages->first(), 'Kirby\Cms\Page')) {
$pages = $pages->group(fn () => 'pages');
}
foreach ($pages as $group) {
$index = $sitemap->create($group ? $group->first()->intendedTemplate()->name() : 'pages');
foreach ($group as $page) {
$url = $index->createUrl($page->metadata()->canonicalUrl())
->lastmod($page->modified() ?? (int)(date('c')))
->changefreq(is_callable($changefreq = option('tobimori.seo.sitemap.changefreq')) ? $changefreq($page) : $changefreq)
->priority(is_callable($priority = option('tobimori.seo.sitemap.priority')) ? $priority($page) : $priority);
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
$alternates = [];
foreach (kirby()->languages() as $language) {
// only if this language is translated for this page and exists
if ($page->translation($language->code())->exists()) {
/*
* Specification: "lists every alternate version of the page, including itself."
* https://developers.google.com/search/docs/specialty/international/localized-versions#sitemap
*/
$alternates[] =
[
'hreflang' => Meta::toBCP47($language),
'href' => $page->url($language->code()),
];
}
}
// add x-default
$alternates[] =
[
'hreflang' => 'x-default',
'href' => $page->indexUrl(),
];
$url->alternates($alternates);
}
}
}
};

View file

@ -0,0 +1,40 @@
<?php
use Kirby\Cms\Language;
use tobimori\Seo\SchemaSingleton;
use tobimori\Seo\Seo;
return [
'schema' => fn ($type) => Seo::option('components.schema')::getInstance($type, $this),
'schemas' => fn () => Seo::option('components.schema')::getInstances($this),
'metadata' => fn (?Language $lang = null) => new (Seo::option('components.meta'))($this, $lang),
'robots' => fn (?Language $lang = null) => $this->metadata($lang)->robots(),
'indexUrl' => function () {
// Google: "fallback page for unmatched languages, especially on language/country selectors or auto-redirecting home pages."
// https://developers.google.com/search/docs/specialty/international/localized-versions#all-method-guidelines
// returns the index URL of the site, e.g. https://example.com/
$kirbyUrl = $this->kirby()->url('index');
$defaultLang = $this->kirby()->defaultLanguage()?->code();
// returns the site URL, e.g. https://example.com/en
// we have to request the default language so we don't get localized slugs
$siteUrl = $this->site()->url($defaultLang);
// returns the full URL of the current page in the default language, e.g. https://example.com/en/about
// again, request default language otherwise there is a mismatch in language prefix between the site URL and the current page URL
$thisUrl = $this->url($defaultLang);
// remove the part form the URL that is specific to the 'site'
// this is usually the language code prefix
// https://example.com/en/ + https://example.com/en/about -> https://example.com/about
if (strpos($siteUrl, $kirbyUrl) === 0 && strlen($siteUrl) > strlen($kirbyUrl)) {
if (strpos($thisUrl, $siteUrl) === 0) {
$pathAfterSite = substr($thisUrl, strlen($siteUrl));
return "{$kirbyUrl}{$pathAfterSite}";
}
}
return $thisUrl;
},
];

View file

@ -0,0 +1,281 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Http\Response;
use Kirby\Data\Json;
use tobimori\Seo\Seo;
use tobimori\Seo\Sitemap\SitemapIndex;
return [
[
'pattern' => 'indexnow-(:any).txt',
'method' => 'GET',
'action' => function (string $key) {
if (Seo::option('indexnow.enabled') && Seo::option('components.indexnow')::verifyKey($key)) {
return new Response($key, 'text/plain', 200);
}
$this->next();
}
],
[
'pattern' => 'robots.txt',
'method' => 'GET|HEAD',
'action' => function () {
if (Seo::option('robots.active')) {
$content = snippet('seo/robots.txt', [], true);
return new Response($content, 'text/plain', 200);
}
$this->next();
}
],
[
'pattern' => 'robots.txt',
'method' => 'OPTIONS',
'action' => function () {
if (Seo::option('robots.active')) {
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'robots.txt',
'method' => 'ALL',
'action' => function () {
if (Seo::option('robots.active')) {
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap',
'method' => 'GET|HEAD',
'action' => function () {
if (!Seo::option('sitemap.redirect') || !Seo::option('sitemap.active')) {
$this->next();
}
go('/sitemap.xml');
}
],
[
'pattern' => 'sitemap',
'method' => 'OPTIONS',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap',
'method' => 'ALL',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap.xsl',
'method' => 'GET',
'action' => function () {
if (!Seo::option('sitemap.active')) {
$this->next();
}
kirby()->response()->type('text/xsl');
$lang = Seo::option('sitemap.locale', 'en');
kirby()->setCurrentTranslation($lang);
return Page::factory([
'slug' => 'sitemap',
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
],
])->render(contentType: 'xsl');
}
],
[
'pattern' => 'sitemap.xsl',
'method' => 'OPTIONS',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('', 'text/plain', 204, ['Allow' => 'GET']);
}
$this->next();
}
],
[
'pattern' => 'sitemap.xsl',
'method' => 'ALL',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET']);
}
$this->next();
}
],
[
'pattern' => 'sitemap.xml',
'method' => 'GET|HEAD',
'action' => function () {
if (!Seo::option('sitemap.active', true)) {
$this->next();
}
SitemapIndex::instance()->generate();
kirby()->response()->type('text/xml');
return Page::factory([
'slug' => 'sitemap',
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
'index' => null,
],
])->render(contentType: 'xml');
}
],
[
'pattern' => 'sitemap.xml',
'method' => 'OPTIONS',
'action' => function () {
if (Seo::option('sitemap.active', true)) {
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap.xml',
'method' => 'ALL',
'action' => function () {
if (Seo::option('sitemap.active', true)) {
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap-(:any).xml',
'method' => 'GET|HEAD',
'action' => function (string $index) {
if (!Seo::option('sitemap.active', true)) {
$this->next();
}
SitemapIndex::instance()->generate();
if (!SitemapIndex::instance()->isValidIndex($index)) {
$this->next();
}
kirby()->response()->type('text/xml');
return Page::factory([
'slug' => "sitemap-{$index}",
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
'index' => $index,
],
])->render(contentType: 'xml');
}
],
[
'pattern' => 'sitemap-(:any).xml',
'method' => 'OPTIONS',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
[
'pattern' => 'sitemap-(:any).xml',
'method' => 'ALL',
'action' => function () {
if (Seo::option('sitemap.active')) {
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
}
$this->next();
}
],
// Google Search Console OAuth
[
'pattern' => '__seo/gsc/auth',
'method' => 'GET',
'action' => function () {
$kirby = App::instance();
if (!$kirby->user() || !Seo::option('searchConsole.enabled') || !Seo::option('components.gsc')::hasCredentials()) {
go($kirby->site()->panel()->url());
}
$return = $kirby->request()->get('return') ?? $kirby->site()->panel()->url();
$state = base64_encode(Json::encode([
'csrf' => bin2hex(random_bytes(16)),
'return' => $return
]));
$redirectUri = rtrim($kirby->url(), '/') . '/__seo/gsc/callback';
go(Seo::option('components.gsc')::authUrl($redirectUri, $state));
}
],
[
'pattern' => '__seo/gsc/callback',
'method' => 'GET',
'action' => function () {
$kirby = App::instance();
if (!$kirby->user()) {
go($kirby->site()->panel()->url());
}
$request = $kirby->request();
$state = Json::decode(base64_decode($request->get('state')));
if (!$state || empty($state['csrf'])) {
throw new \Exception('Invalid OAuth state');
}
if ($error = $request->get('error')) {
throw new \Exception("OAuth error: {$error}");
}
if (!($code = $request->get('code'))) {
throw new \Exception('No authorization code received');
}
$redirectUri = rtrim($kirby->url(), '/') . '/__seo/gsc/callback';
Seo::option('components.gsc')::exchangeCode($code, $redirectUri);
// redirect back to where the user came from
$return = $state['return'] ?? $kirby->site()->panel()->url();
go($return);
}
],
];

View file

@ -0,0 +1,134 @@
<?php
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use tobimori\Seo\Seo;
return [
'seo-preview' => [
'mixins' => ['headline'],
'computed' => [
'options' => fn () => A::map(option('tobimori.seo.previews'), fn ($item) => [
'value' => $item,
'text' => t("seo.sections.preview.{$item}")
]),
'meta' => function () {
$model = $this->model();
if ($model instanceof Site || $model instanceof Page) {
// clone the model with the content from the changes version
$changesVersion = $model->version('changes');
if ($changesVersion->exists('current')) {
$model = $model->clone(['content' => $changesVersion->content()->toArray()]);
}
// if it's a site, fall back to the home page for preview data
$model = $model instanceof Site ? $model->homePage() : $model;
if (!$model) {
return null;
}
$meta = $model->metadata();
return [
'page' => $model->slug(),
'url' => $model->url(),
'pageTitle' => Str::unhtml($model->title()->value()),
'title' => Str::unhtml($meta->metaTitle()->value()),
'description' => Str::unhtml($meta->metaDescription()->value()),
'ogSiteName' => Str::unhtml($meta->ogSiteName()->value()),
'ogTitle' => Str::unhtml($meta->ogTitle()->value()),
'ogDescription' => Str::unhtml($meta->ogDescription()->value()),
'ogImage' => $meta->ogImage(),
'cropOgImage' => $meta->cropOgImage()->toBool(),
'panelUrl' => method_exists($model, 'panel') ? "{$model->panel()?->url()}?tab=seo" : null,
];
}
return null;
}
]
],
'heading-structure' => [
'mixins' => ['headline'],
'computed' => [
'data' => function () {
$model = $this->model();
if (!($model instanceof Page)) {
// only works for pages (not site, files, etc.)
return [];
}
// In Kirby 5, use the changes version if it exists
// clone the model with the content from the changes version
$changesVersion = $model->version('changes');
if ($changesVersion->exists('current')) {
$model = $model->clone(['content' => $changesVersion->content()->toArray()]);
}
// Render the page
$page = $model->render();
$dom = new DOMDocument();
$dom->loadHTML(htmlspecialchars_decode(mb_convert_encoding(htmlentities($page, ENT_COMPAT, 'UTF-8'), 'ISO-8859-1', 'UTF-8'), ENT_QUOTES), libxml_use_internal_errors(true));
$xpath = new DOMXPath($dom);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
$data = [];
foreach ($headings as $heading) {
$data[] = [
'level' => (int)str_replace('h', '', $heading->nodeName),
'text' => $heading->textContent,
];
}
return $data;
}
]
],
'seo-search-console' => [
'mixins' => ['headline'],
'computed' => [
'status' => function () {
if (!Seo::option('components.gsc')::hasCredentials()) {
return 'NO_CREDENTIALS';
}
if (!Seo::option('components.gsc')::isConnected()) {
return 'NOT_CONNECTED';
}
if (!Seo::option('components.gsc')::property()) {
return 'SELECT_PROPERTY';
}
return 'CONNECTED';
},
'property' => fn () => Seo::option('components.gsc')::property(),
'pageUrl' => function () {
$model = $this->model();
if ($model instanceof Page) {
return '/' . $model->uri();
}
return null;
},
'data' => function () {
$gsc = Seo::option('components.gsc');
if (!$gsc::hasCredentials() || !$gsc::isConnected() || !$gsc::property()) {
return [];
}
$metric = kirby()->request()->get('metric', 'clicks');
$limit = (int) kirby()->request()->get('limit', 10);
$asc = in_array($metric, ['position', 'query']);
try {
return $gsc::queryForModel($this->model(), $metric, $limit, $asc);
} catch (\Exception $e) {
return [];
}
}
]
]
];

View file

@ -0,0 +1,48 @@
<?php
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use tobimori\Seo\Seo;
return [
'schema' => fn ($type) => Seo::option('components.schema')::getInstance($type),
'schemas' => fn () => Seo::option('components.schema')::getInstances(),
'lang' => fn () => Seo::option('components.meta')::normalizeLocale(Seo::option('default.locale', args: [$this->homePage()]), '-'),
'canonicalFor' => function (string $url, bool $useRootUrl = false) {
// Determine the base URL
$base = Seo::option('canonical.base', Seo::option('canonicalBase'));
if (!$base) {
// If useRootUrl is true or this is a multilang site requesting root URL, use kirby()->url()
if ($useRootUrl && kirby()->multilang()) {
$base = kirby()->url();
} else {
$base = $this->url();
}
}
if (Str::startsWith($url, $base)) {
$canonicalUrl = $url;
} else {
$path = Url::path($url);
$canonicalUrl = url($base . '/' . $path);
}
$trailingSlash = Seo::option('canonical.trailingSlash', false);
if ($trailingSlash) {
// check if URL has a file extension (like .xml, .jpg, .pdf, etc.)
$path = parse_url($canonicalUrl, PHP_URL_PATH) ?? '';
$pathInfo = pathinfo($path);
$hasExtension = !empty($pathInfo['extension'] ?? null);
// Only add trailing slash if:
// - URL doesn't already have one
// - URL doesn't have a file extension
// - URL isn't just the base domain
if (!Str::endsWith($canonicalUrl, '/') && !$hasExtension && $canonicalUrl !== $base) {
$canonicalUrl .= '/';
}
}
return $canonicalUrl;
}
];