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:
parent
ff215de723
commit
04a14a7f1f
70 changed files with 6142 additions and 3 deletions
145
site/plugins/kirby-seo/config/areas.php
Normal file
145
site/plugins/kirby-seo/config/areas.php
Normal 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'
|
||||
];
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
130
site/plugins/kirby-seo/config/fields.php
Normal file
130
site/plugins/kirby-seo/config/fields.php
Normal 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();
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
64
site/plugins/kirby-seo/config/hooks.php
Normal file
64
site/plugins/kirby-seo/config/hooks.php
Normal 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());
|
||||
}
|
||||
},
|
||||
];
|
||||
112
site/plugins/kirby-seo/config/options.php
Normal file
112
site/plugins/kirby-seo/config/options.php
Normal 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
|
||||
];
|
||||
32
site/plugins/kirby-seo/config/options/ai.php
Normal file
32
site/plugins/kirby-seo/config/options/ai.php
Normal 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',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
29
site/plugins/kirby-seo/config/options/indexnow.php
Normal file
29
site/plugins/kirby-seo/config/options/indexnow.php
Normal 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']],
|
||||
],
|
||||
];
|
||||
55
site/plugins/kirby-seo/config/options/sitemap.php
Normal file
55
site/plugins/kirby-seo/config/options/sitemap.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
40
site/plugins/kirby-seo/config/page-methods.php
Normal file
40
site/plugins/kirby-seo/config/page-methods.php
Normal 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;
|
||||
},
|
||||
];
|
||||
281
site/plugins/kirby-seo/config/routes.php
Normal file
281
site/plugins/kirby-seo/config/routes.php
Normal 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);
|
||||
}
|
||||
],
|
||||
];
|
||||
134
site/plugins/kirby-seo/config/sections.php
Normal file
134
site/plugins/kirby-seo/config/sections.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
];
|
||||
48
site/plugins/kirby-seo/config/site-methods.php
Normal file
48
site/plugins/kirby-seo/config/site-methods.php
Normal 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;
|
||||
}
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue