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
62
site/plugins/kirby-seo/classes/Ai.php
Normal file
62
site/plugins/kirby-seo/classes/Ai.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
|
||||
use function is_string;
|
||||
use function is_array;
|
||||
|
||||
/**
|
||||
* Ai facade
|
||||
*/
|
||||
class Ai
|
||||
{
|
||||
private static array $providers = [];
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return (bool)Seo::option('ai.enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a provider instance for the given ID or the default provider.
|
||||
*/
|
||||
public static function provider(string|null $providerId = null): Driver
|
||||
{
|
||||
$providerId ??= Seo::option('ai.provider');
|
||||
|
||||
if (isset(self::$providers[$providerId])) {
|
||||
return self::$providers[$providerId];
|
||||
}
|
||||
|
||||
$config = Seo::option("ai.providers.{$providerId}");
|
||||
if (!is_array($config)) {
|
||||
throw new KirbyException("AI provider \"{$providerId}\" is not defined.");
|
||||
}
|
||||
|
||||
$driver = $config['driver'] ?? null;
|
||||
if (!is_string($driver) || $driver === '') {
|
||||
throw new KirbyException("AI provider \"{$providerId}\" is missing a driver reference.");
|
||||
}
|
||||
|
||||
if (!is_subclass_of($driver, Driver::class)) {
|
||||
throw new KirbyException("AI provider driver \"{$driver}\" must extend " . Driver::class . '.');
|
||||
}
|
||||
|
||||
return self::$providers[$providerId] = new $driver($providerId);
|
||||
}
|
||||
|
||||
public static function streamTask(string $taskId, array $variables = []): Generator
|
||||
{
|
||||
$snippet = "seo/prompts/tasks/{$taskId}";
|
||||
$prompt = trim(snippet($snippet, $variables, return: true));
|
||||
if ($prompt === '') {
|
||||
throw new KirbyException("AI prompt snippet \"{$snippet}\" is missing or empty.");
|
||||
}
|
||||
|
||||
return self::provider()->stream($prompt, /* todo custom model here */);
|
||||
}
|
||||
}
|
||||
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
/**
|
||||
* Value object representing a streamed AI response chunk.
|
||||
*/
|
||||
final class Chunk
|
||||
{
|
||||
public const string TYPE_STREAM_START = 'stream-start';
|
||||
public const string TYPE_STREAM_END = 'stream-end';
|
||||
public const string TYPE_TEXT_START = 'text-start';
|
||||
public const string TYPE_TEXT_DELTA = 'text-delta';
|
||||
public const string TYPE_TEXT_COMPLETE = 'text-complete';
|
||||
public const string TYPE_THINKING_START = 'thinking-start';
|
||||
public const string TYPE_THINKING_DELTA = 'thinking-delta';
|
||||
public const string TYPE_THINKING_COMPLETE = 'thinking-complete';
|
||||
public const string TYPE_TOOL_CALL = 'tool-call';
|
||||
public const string TYPE_TOOL_RESULT = 'tool-result';
|
||||
public const string TYPE_ERROR = 'error';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $type,
|
||||
public readonly mixed $payload = null,
|
||||
public readonly ?string $text = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function streamStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_START, $payload);
|
||||
}
|
||||
|
||||
public static function streamEnd(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_END, $payload);
|
||||
}
|
||||
|
||||
public static function textStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_START, $payload);
|
||||
}
|
||||
|
||||
public static function textDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function textComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_START, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function thinkingComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function toolCall(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_CALL, $payload);
|
||||
}
|
||||
|
||||
public static function toolResult(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_RESULT, $payload);
|
||||
}
|
||||
|
||||
public static function error(string $message, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_ERROR, [
|
||||
'message' => $message,
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isStreamStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_START;
|
||||
}
|
||||
|
||||
public function isStreamEnd(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_END;
|
||||
}
|
||||
|
||||
public function isTextStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_START;
|
||||
}
|
||||
|
||||
public function isTextDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_DELTA;
|
||||
}
|
||||
|
||||
public function isTextComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_COMPLETE;
|
||||
}
|
||||
|
||||
public function isThinkingStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_START;
|
||||
}
|
||||
|
||||
public function isThinkingDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_DELTA;
|
||||
}
|
||||
|
||||
public function isThinkingComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_COMPLETE;
|
||||
}
|
||||
|
||||
public function isToolCall(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_CALL;
|
||||
}
|
||||
|
||||
public function isToolResult(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_RESULT;
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_ERROR;
|
||||
}
|
||||
}
|
||||
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
abstract class Driver
|
||||
{
|
||||
public function __construct(protected string $providerId)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a response for the given prompt and optional context data.
|
||||
*
|
||||
* @param string $prompt User prompt (e.g. Tasks).
|
||||
* @param string|null $model Model to use.
|
||||
*
|
||||
* @return Generator<int, Chunk, mixed, void>
|
||||
*/
|
||||
abstract public function stream(string $prompt, string|null $model = null): Generator;
|
||||
|
||||
/**
|
||||
* Returns a configuration value or throws when required.
|
||||
*/
|
||||
protected function config(string $key, mixed $default = null, bool $required = false): mixed
|
||||
{
|
||||
$value = Seo::option("ai.providers.{$this->providerId}.config.{$key}", $default);
|
||||
|
||||
if ($required === true && ($value === null || $value === '')) {
|
||||
throw new InvalidArgumentException(
|
||||
"Missing required \"{$key}\" configuration for driver " . static::class . '.'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
103
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
103
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class Anthropic extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
||||
protected const string DEFAULT_MODEL = 'claude-4-5-haiku';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
string $prompt,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"x-api-key: {$apiKey}",
|
||||
'anthropic-version: 2023-06-01',
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt]
|
||||
],
|
||||
'max_tokens' => 4096,
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
// handle message start event
|
||||
if ($type === 'message_start') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block start (beginning of text output)
|
||||
if ($type === 'content_block_start') {
|
||||
$contentBlock = $event['content_block'] ?? [];
|
||||
if (($contentBlock['type'] ?? null) === 'text') {
|
||||
yield Chunk::textStart($event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block delta (text chunks)
|
||||
if ($type === 'content_block_delta') {
|
||||
$delta = $event['delta'] ?? [];
|
||||
if (($delta['type'] ?? null) === 'text_delta') {
|
||||
$text = $delta['text'] ?? '';
|
||||
if ($text !== '') {
|
||||
yield Chunk::textDelta($text, $event);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block stop (end of text block)
|
||||
if ($type === 'content_block_stop') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message stop (end of stream)
|
||||
if ($type === 'message_stop') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle ping events (keep-alive)
|
||||
if ($type === 'ping') {
|
||||
// ignore ping events
|
||||
return;
|
||||
}
|
||||
|
||||
// handle error events
|
||||
if ($type === 'error') {
|
||||
$error = $event['error'] ?? [];
|
||||
$message = $error['message'] ?? 'Unknown Anthropic streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message delta (contains usage info)
|
||||
if ($type === 'message_delta') {
|
||||
// we could extract usage info here if needed
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
82
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
82
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class OpenAi extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/responses';
|
||||
protected const string DEFAULT_MODEL = 'gpt-5-mini-2025-08-07';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
string $prompt,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"Authorization: Bearer {$apiKey}",
|
||||
];
|
||||
if ($organization = $this->config('organization')) {
|
||||
$headers[] = "OpenAI-Organization: {$organization}";
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'input' => $prompt,
|
||||
// instructions does not work for e.g. openrouter so let's just put everything in user prompt
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
if ($type === 'response.created') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.in_progress') {
|
||||
yield Chunk::textStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.delta') {
|
||||
$delta = $event['delta'] ?? '';
|
||||
if ($delta !== '') {
|
||||
yield Chunk::textDelta($delta, $event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.done') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.completed') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_item.added' && ($event['item']['type'] ?? null) === 'reasoning') {
|
||||
yield Chunk::thinkingStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.error') {
|
||||
$message = $event['error']['message'] ?? 'Unknown OpenAI streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
196
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
196
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
|
||||
use function curl_close;
|
||||
use function curl_errno;
|
||||
use function curl_error;
|
||||
use function curl_getinfo;
|
||||
use function curl_init;
|
||||
use function curl_multi_add_handle;
|
||||
use function curl_multi_close;
|
||||
use function curl_multi_exec;
|
||||
use function curl_multi_init;
|
||||
use function curl_multi_remove_handle;
|
||||
use function curl_multi_select;
|
||||
use function curl_setopt_array;
|
||||
use function strlen;
|
||||
use function sprintf;
|
||||
use function is_array;
|
||||
|
||||
use const CURLOPT_HTTPHEADER;
|
||||
use const CURLOPT_POST;
|
||||
use const CURLOPT_POSTFIELDS;
|
||||
use const CURLOPT_RETURNTRANSFER;
|
||||
use const CURLOPT_TIMEOUT;
|
||||
use const CURLOPT_WRITEFUNCTION;
|
||||
use const CURLINFO_HTTP_CODE;
|
||||
use const CURLM_CALL_MULTI_PERFORM;
|
||||
|
||||
final class SseStream
|
||||
{
|
||||
private const int ERROR_CONTEXT_LIMIT = 8192;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
private readonly array $headers,
|
||||
private readonly array $payload,
|
||||
private readonly int $timeout = 120
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
public function stream(callable $mapper): Generator
|
||||
{
|
||||
$buffer = '';
|
||||
$response = '';
|
||||
$handle = curl_init($this->endpoint);
|
||||
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $this->headers,
|
||||
CURLOPT_POSTFIELDS => json_encode(
|
||||
$this->payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, $data) use (&$buffer, &$response) {
|
||||
$buffer .= $data;
|
||||
$currentLength = strlen($response);
|
||||
|
||||
if ($currentLength < self::ERROR_CONTEXT_LIMIT) {
|
||||
$response .= substr($data, 0, self::ERROR_CONTEXT_LIMIT - $currentLength);
|
||||
}
|
||||
|
||||
return strlen($data);
|
||||
},
|
||||
]);
|
||||
|
||||
$multi = curl_multi_init();
|
||||
curl_multi_add_handle($multi, $handle);
|
||||
|
||||
try {
|
||||
$running = null;
|
||||
do {
|
||||
$status = curl_multi_exec($multi, $running);
|
||||
|
||||
if ($status === CURLM_CALL_MULTI_PERFORM) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper);
|
||||
|
||||
if ($running) {
|
||||
curl_multi_select($multi, 0.1);
|
||||
}
|
||||
} while ($running);
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper, true);
|
||||
|
||||
$errno = curl_errno($handle);
|
||||
if ($errno) {
|
||||
throw new KirbyException(curl_error($handle) ?: 'Streaming request failed.', $errno);
|
||||
}
|
||||
|
||||
$code = curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($code !== null && $code >= 400) {
|
||||
$message = sprintf('Streaming request failed (%d)', $code);
|
||||
$body = trim($response);
|
||||
|
||||
if ($body !== '') {
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$body = $decoded['error']['message'] ?? $decoded['message'] ?? $body;
|
||||
}
|
||||
|
||||
if (strlen($body) > 200) {
|
||||
$body = substr($body, 0, 200) . '...';
|
||||
}
|
||||
|
||||
$message .= ': ' . preg_replace('/\s+/', ' ', $body);
|
||||
}
|
||||
|
||||
throw new KirbyException($message);
|
||||
}
|
||||
} finally {
|
||||
curl_multi_remove_handle($multi, $handle);
|
||||
curl_multi_close($multi);
|
||||
curl_close($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function drainBuffer(string &$buffer, callable $mapper, bool $final = false): Generator
|
||||
{
|
||||
while (
|
||||
preg_match('/\r?\n\r?\n/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1
|
||||
) {
|
||||
$pos = $match[0][1];
|
||||
$len = strlen($match[0][0]);
|
||||
$frame = substr($buffer, 0, $pos);
|
||||
$buffer = substr($buffer, $pos + $len);
|
||||
|
||||
foreach ($this->mapFrame($frame, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
if ($final && trim($buffer) !== '') {
|
||||
foreach ($this->mapFrame($buffer, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function mapFrame(string $frame, callable $mapper): Generator
|
||||
{
|
||||
$frame = trim($frame);
|
||||
|
||||
if ($frame === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = '';
|
||||
|
||||
foreach (preg_split("/\r\n|\n|\r/", $frame) as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || str_starts_with($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$payload .= substr($line, 5);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = trim($payload);
|
||||
if ($payload === '' || $payload === '[DONE]') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = json_decode($payload, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield from $mapper($event);
|
||||
}
|
||||
}
|
||||
51
site/plugins/kirby-seo/classes/Buttons/RobotsViewButton.php
Normal file
51
site/plugins/kirby-seo/classes/Buttons/RobotsViewButton.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButton;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
class RobotsViewButton extends ViewButton
|
||||
{
|
||||
public function __construct(Page $model)
|
||||
{
|
||||
if (Seo::option('tobimori.seo.robots.active') !== true) {
|
||||
parent::__construct(
|
||||
model: $model,
|
||||
disabled: true,
|
||||
style: 'display: none'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$robots = $model->robots();
|
||||
|
||||
$theme = 'positive-icon';
|
||||
$icon = 'robots';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.index');
|
||||
|
||||
if (Str::contains($robots, 'no') && !Str::contains($robots, 'noindex')) {
|
||||
$theme = 'notice-icon';
|
||||
$icon = 'robots-off';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.any');
|
||||
}
|
||||
|
||||
if (Str::contains($robots, 'noindex')) {
|
||||
$theme = 'negative-icon';
|
||||
$icon = 'robots-off';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.noindex');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
model: $model,
|
||||
icon: $icon,
|
||||
text: $text,
|
||||
theme: $theme,
|
||||
link: $model->panel()->url() . '?tab=seo',
|
||||
responsive: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButton;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
class UtmShareViewButton extends ViewButton
|
||||
{
|
||||
public function __construct(Page|Site $model)
|
||||
{
|
||||
parent::__construct(
|
||||
model: $model,
|
||||
dialog: "seo/utm-share/{$model->panel()->path()}",
|
||||
icon: 'share',
|
||||
title: I18n::translate('seo.utmShare.button')
|
||||
);
|
||||
}
|
||||
}
|
||||
39
site/plugins/kirby-seo/classes/Dialogs/UtmShareDialog.php
Normal file
39
site/plugins/kirby-seo/classes/Dialogs/UtmShareDialog.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Dialogs;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
|
||||
class UtmShareDialog
|
||||
{
|
||||
protected Page|Site $model;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($path === 'site') {
|
||||
$this->model = $kirby->site();
|
||||
} else {
|
||||
$id = preg_replace('/^pages\//', '', $path);
|
||||
$this->model = Find::page($id);
|
||||
}
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$url = $this->model instanceof Site
|
||||
? $this->model->homePage()->url()
|
||||
: $this->model->url();
|
||||
|
||||
return [
|
||||
'component' => 'k-seo-utm-share-dialog',
|
||||
'props' => [
|
||||
'pageUrl' => $url
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
386
site/plugins/kirby-seo/classes/GoogleSearchConsole.php
Normal file
386
site/plugins/kirby-seo/classes/GoogleSearchConsole.php
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Cache\Cache;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Remote;
|
||||
use Kirby\Http\Uri;
|
||||
|
||||
use function array_slice;
|
||||
|
||||
class GoogleSearchConsole
|
||||
{
|
||||
protected const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
protected const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
protected const SCOPES = 'email https://www.googleapis.com/auth/webmasters.readonly';
|
||||
protected const CACHE_DURATION = 60 * 24; // 24 hours in minutes
|
||||
|
||||
protected static ?array $tokens = null;
|
||||
|
||||
protected static function cache(): Cache
|
||||
{
|
||||
return App::instance()->cache('tobimori.seo.searchConsole');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth credentials from config
|
||||
*/
|
||||
public static function credentials(): ?array
|
||||
{
|
||||
return Seo::option('searchConsole.credentials.web');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials are configured
|
||||
*/
|
||||
public static function hasCredentials(): bool
|
||||
{
|
||||
$credentials = static::credentials();
|
||||
return !empty($credentials['client_id']) && !empty($credentials['client_secret']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token file path
|
||||
*/
|
||||
protected static function tokenPath(): string
|
||||
{
|
||||
$path = Seo::option('searchConsole.tokenPath');
|
||||
return is_callable($path) ? $path() : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tokens from file
|
||||
*/
|
||||
public static function tokens(): ?array
|
||||
{
|
||||
if (static::$tokens !== null) {
|
||||
return static::$tokens;
|
||||
}
|
||||
|
||||
$path = static::tokenPath();
|
||||
if (!F::exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
static::$tokens = Json::read($path);
|
||||
return static::$tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tokens to file
|
||||
*/
|
||||
protected static function saveTokens(array $tokens): void
|
||||
{
|
||||
static::$tokens = $tokens;
|
||||
Json::write(static::tokenPath(), $tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have valid tokens
|
||||
*/
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
$tokens = static::tokens();
|
||||
return !empty($tokens['access_token']) && !empty($tokens['refresh_token']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL
|
||||
*/
|
||||
public static function authUrl(string $redirectUri, string $state): string
|
||||
{
|
||||
$credentials = static::credentials();
|
||||
|
||||
$uri = new Uri(static::AUTH_URL);
|
||||
$uri->query()->merge([
|
||||
'client_id' => $credentials['client_id'],
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'consent',
|
||||
'scope' => static::SCOPES,
|
||||
'state' => $state
|
||||
]);
|
||||
|
||||
return $uri->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
public static function exchangeCode(string $code, string $redirectUri): array
|
||||
{
|
||||
$credentials = static::credentials();
|
||||
|
||||
$response = Remote::request(static::TOKEN_URL, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded'
|
||||
],
|
||||
'data' => [
|
||||
'client_id' => $credentials['client_id'],
|
||||
'client_secret' => $credentials['client_secret'],
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => $redirectUri
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['error'])) {
|
||||
throw new \Exception($data['error_description'] ?? $data['error']);
|
||||
}
|
||||
|
||||
// store tokens with expiry timestamp
|
||||
$tokens = [
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'expires_at' => time() + $data['expires_in']
|
||||
];
|
||||
|
||||
static::saveTokens($tokens);
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token
|
||||
*/
|
||||
public static function refreshToken(): string
|
||||
{
|
||||
$credentials = static::credentials();
|
||||
$tokens = static::tokens();
|
||||
|
||||
if (empty($tokens['refresh_token'])) {
|
||||
throw new \Exception('No refresh token available');
|
||||
}
|
||||
|
||||
$response = Remote::request(static::TOKEN_URL, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded'
|
||||
],
|
||||
'data' => [
|
||||
'client_id' => $credentials['client_id'],
|
||||
'client_secret' => $credentials['client_secret'],
|
||||
'refresh_token' => $tokens['refresh_token'],
|
||||
'grant_type' => 'refresh_token'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['error'])) {
|
||||
throw new \Exception($data['error_description'] ?? $data['error']);
|
||||
}
|
||||
|
||||
$tokens['access_token'] = $data['access_token'];
|
||||
$tokens['expires_at'] = time() + $data['expires_in'];
|
||||
|
||||
static::saveTokens($tokens);
|
||||
return $tokens['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if needed
|
||||
*/
|
||||
public static function accessToken(): string
|
||||
{
|
||||
$tokens = static::tokens();
|
||||
|
||||
if (empty($tokens['access_token'])) {
|
||||
throw new \Exception('Not connected to Google Search Console');
|
||||
}
|
||||
|
||||
// refresh if expired or expiring soon (within 5 min)
|
||||
if ($tokens['expires_at'] < time() + 300) {
|
||||
return static::refreshToken();
|
||||
}
|
||||
|
||||
return $tokens['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the connected property URL
|
||||
*/
|
||||
public static function property(): ?string
|
||||
{
|
||||
$tokens = static::tokens();
|
||||
return $tokens['property'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching property for a site URL
|
||||
*/
|
||||
public static function findMatchingProperty(string $siteUrl): ?string
|
||||
{
|
||||
$properties = static::listProperties();
|
||||
if (empty($properties)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$siteHost = parse_url($siteUrl, PHP_URL_HOST);
|
||||
|
||||
foreach ($properties as $p) {
|
||||
$propUrl = $p['siteUrl'];
|
||||
|
||||
// check domain properties (sc-domain:example.com)
|
||||
if (str_starts_with($propUrl, 'sc-domain:')) {
|
||||
$domain = substr($propUrl, 10);
|
||||
if ($domain === $siteHost || str_ends_with($siteHost, ".{$domain}")) {
|
||||
return $propUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// check URL prefix properties
|
||||
if (str_starts_with($siteUrl, $propUrl) || $propUrl === "{$siteUrl}/") {
|
||||
return $propUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to first property
|
||||
return $properties[0]['siteUrl'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the connected property URL
|
||||
*/
|
||||
public static function setProperty(string $property): void
|
||||
{
|
||||
$tokens = static::tokens() ?? [];
|
||||
$tokens['property'] = $property;
|
||||
static::saveTokens($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect (remove tokens)
|
||||
*/
|
||||
public static function disconnect(): void
|
||||
{
|
||||
$path = static::tokenPath();
|
||||
if (F::exists($path)) {
|
||||
F::remove($path);
|
||||
}
|
||||
static::$tokens = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available GSC properties
|
||||
*/
|
||||
public static function listProperties(): array
|
||||
{
|
||||
$response = Remote::request('https://www.googleapis.com/webmasters/v3/sites', [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . static::accessToken()
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['error'])) {
|
||||
throw new \Exception($data['error']['message'] ?? 'Failed to list properties');
|
||||
}
|
||||
|
||||
return $data['siteEntry'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query search analytics data (fetches max 25k rows from Google, cached for 24h)
|
||||
*/
|
||||
public static function query(array $options = []): array
|
||||
{
|
||||
$property = static::property();
|
||||
if (!$property) {
|
||||
throw new \Exception('No property selected');
|
||||
}
|
||||
|
||||
$body = [
|
||||
'startDate' => $options['startDate'] ?? date('Y-m-d', strtotime('-28 days')),
|
||||
'endDate' => $options['endDate'] ?? date('Y-m-d', strtotime('-1 day')),
|
||||
'dimensions' => $options['dimensions'] ?? ['query'],
|
||||
'rowLimit' => 25000
|
||||
];
|
||||
|
||||
if (!empty($options['page'])) {
|
||||
$body['dimensionFilterGroups'] = [[
|
||||
'filters' => [[
|
||||
'dimension' => 'page',
|
||||
'operator' => $options['pageOperator'] ?? 'equals',
|
||||
'expression' => $options['page']
|
||||
]]
|
||||
]];
|
||||
}
|
||||
|
||||
$cacheKey = md5($property . json_encode($body));
|
||||
|
||||
$cached = static::cache()->get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$uri = new Uri('https://www.googleapis.com/webmasters/v3/sites');
|
||||
$uri->setPath($uri->path() . '/' . urlencode($property) . '/searchAnalytics/query');
|
||||
|
||||
$response = Remote::request($uri->toString(), [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . static::accessToken(),
|
||||
'Content-Type' => 'application/json'
|
||||
],
|
||||
'data' => json_encode($body)
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['error'])) {
|
||||
throw new \Exception($data['error']['message'] ?? 'Failed to query search analytics');
|
||||
}
|
||||
|
||||
$rows = $data['rows'] ?? [];
|
||||
|
||||
static::cache()->set($cacheKey, $rows, static::CACHE_DURATION);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query search data for a Kirby model (page or site), sorted by metric
|
||||
*/
|
||||
public static function queryForModel($model, string $metric = 'clicks', int $limit = 10, bool $asc = false): array
|
||||
{
|
||||
if ($model instanceof Page) {
|
||||
// try exact URL match first
|
||||
$data = static::query(['page' => $model->url()]);
|
||||
|
||||
// fallback: match by path
|
||||
if (empty($data)) {
|
||||
$path = $model->uri();
|
||||
if ($path) {
|
||||
$data = static::query([
|
||||
'page' => "/{$path}",
|
||||
'pageOperator' => 'contains'
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$data = static::query();
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dir = $asc ? 1 : -1;
|
||||
usort($data, fn ($a, $b) => match ($metric) {
|
||||
'query' => strcasecmp($a['keys'][0], $b['keys'][0]) * $dir,
|
||||
default => ($a[$metric] <=> $b[$metric]) * $dir
|
||||
});
|
||||
|
||||
return array_slice($data, 0, $limit);
|
||||
}
|
||||
}
|
||||
316
site/plugins/kirby-seo/classes/IndexNow.php
Normal file
316
site/plugins/kirby-seo/classes/IndexNow.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Http\Remote;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
use function is_bool;
|
||||
|
||||
class IndexNow
|
||||
{
|
||||
protected static string|null $key = null;
|
||||
|
||||
protected Page $page;
|
||||
protected array $urls = [];
|
||||
protected bool $collected = false;
|
||||
|
||||
public function __construct(Page $page)
|
||||
{
|
||||
$this->page = $page;
|
||||
|
||||
// always add the current page if it's indexable
|
||||
if ($this->isIndexable($page)) {
|
||||
$this->urls[] = $page->url();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect URLs to be indexed based on rules
|
||||
*/
|
||||
public function collect(): self
|
||||
{
|
||||
if ($this->collected) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$rules = Seo::option('indexnow.rules') ?? [];
|
||||
|
||||
foreach ($rules as $pattern => $invalidations) {
|
||||
if (!$this->matchesPattern($pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->collectFromRule($invalidations);
|
||||
}
|
||||
|
||||
$this->urls = array_unique($this->urls);
|
||||
$this->collected = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected urls
|
||||
*/
|
||||
public function urls(): array
|
||||
{
|
||||
if (!$this->collected) {
|
||||
$this->collect();
|
||||
}
|
||||
|
||||
return $this->urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the collected urls
|
||||
*/
|
||||
public function request(): bool
|
||||
{
|
||||
if (!$this->collected) {
|
||||
$this->collect();
|
||||
}
|
||||
|
||||
return static::send($this->urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to send urls to indexnow api
|
||||
*/
|
||||
public static function send(array $urls): bool
|
||||
{
|
||||
if (!Seo::option('indexnow.enabled') || empty($urls)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$firstUrl = $urls[0];
|
||||
$parsedUrl = parse_url($firstUrl);
|
||||
$host = $parsedUrl['host'];
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$path = $parsedUrl['path'] ?? '';
|
||||
|
||||
// don't send requests for local development environments
|
||||
if (App::instance()->environment()->isLocal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// get base path (everything before the page path)
|
||||
$basePath = '';
|
||||
if ($path && $path !== '/') {
|
||||
// find the base path by comparing with site url
|
||||
$siteUrl = App::instance()->site()->url();
|
||||
$siteParsed = parse_url($siteUrl);
|
||||
$basePath = $siteParsed['path'] ?? '';
|
||||
}
|
||||
|
||||
$searchEngine = Seo::option('indexnow.searchEngine');
|
||||
$searchEngine = rtrim($searchEngine, '/');
|
||||
if (!str_contains($searchEngine, '/indexnow')) {
|
||||
$searchEngine .= '/indexnow';
|
||||
}
|
||||
|
||||
$domainUrls = array_filter($urls, fn ($url) => parse_url($url, PHP_URL_HOST) === $host);
|
||||
|
||||
// split into batches of 10,000 (IndexNow limit)
|
||||
$batches = array_chunk(array_values(array_unique($domainUrls)), 10000);
|
||||
$allSuccessful = true;
|
||||
$key = static::key();
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
try {
|
||||
$response = Remote::post($searchEngine, [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'User-Agent' => Seo::userAgent()
|
||||
],
|
||||
'data' => json_encode([
|
||||
'host' => $host,
|
||||
'key' => $key,
|
||||
'keyLocation' => "{$scheme}://{$host}{$basePath}/indexnow-{$key}.txt",
|
||||
'urlList' => $batch
|
||||
])
|
||||
]);
|
||||
|
||||
if ($response->code() > 299) {
|
||||
$allSuccessful = false;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$allSuccessful = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $allSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate the indexnow key
|
||||
* Stored in cache so it persists across requests
|
||||
*/
|
||||
public static function key(): string
|
||||
{
|
||||
return static::$key ??= App::instance()->cache('tobimori.seo.indexnow')->getOrSet('key', fn () => Str::random(32, 'hexLower'), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provided key matches the stored key
|
||||
* Used by the route to verify ownership
|
||||
*/
|
||||
public static function verifyKey(string $providedKey): bool
|
||||
{
|
||||
return $providedKey === static::key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if page matches a pattern (url glob/regex or template)
|
||||
*/
|
||||
protected function matchesPattern(string $pattern): bool
|
||||
{
|
||||
if ($pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// url pattern
|
||||
if (str_contains($pattern, '/')) {
|
||||
return $this->matchesUrlPattern($pattern, $this->page->url());
|
||||
}
|
||||
|
||||
// template pattern
|
||||
return $this->page->intendedTemplate()->name() === $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match url pattern (glob style)
|
||||
*/
|
||||
protected function matchesUrlPattern(string $pattern, string $url): bool
|
||||
{
|
||||
// convert glob to regex
|
||||
$pattern = str_replace(
|
||||
['*', '?', '[', ']'],
|
||||
['.*', '.', '\[', '\]'],
|
||||
$pattern
|
||||
);
|
||||
|
||||
return preg_match("#^{$pattern}$#", parse_url($url, PHP_URL_PATH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect urls based on invalidation rules
|
||||
*/
|
||||
protected function collectFromRule(array $rule): void
|
||||
{
|
||||
// parent(s)
|
||||
if (isset($rule['parent'])) {
|
||||
$this->collectParents($rule['parent']);
|
||||
}
|
||||
|
||||
// children
|
||||
if (isset($rule['children'])) {
|
||||
$this->collectChildren($rule['children']);
|
||||
}
|
||||
|
||||
// siblings
|
||||
if (isset($rule['siblings']) && $rule['siblings'] === true) {
|
||||
$this->collectSiblings();
|
||||
}
|
||||
|
||||
// specific urls
|
||||
if (isset($rule['urls'])) {
|
||||
foreach ($rule['urls'] as $url) {
|
||||
$this->urls[] = url($url);
|
||||
}
|
||||
}
|
||||
|
||||
// pages by template
|
||||
if (isset($rule['templates'])) {
|
||||
$this->collectByTemplates($rule['templates']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent urls
|
||||
*/
|
||||
protected function collectParents($levels): void
|
||||
{
|
||||
$parent = $this->page->parent();
|
||||
$count = is_bool($levels) ? 1 : $levels;
|
||||
$language = App::instance()->language();
|
||||
|
||||
while ($parent && $count > 0) {
|
||||
if ($this->isIndexable($parent)) {
|
||||
$this->urls[] = $parent->url($language?->code());
|
||||
}
|
||||
$parent = $parent->parent();
|
||||
$count--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect children urls
|
||||
*/
|
||||
protected function collectChildren($depth): void
|
||||
{
|
||||
$maxDepth = is_bool($depth) ? null : $depth;
|
||||
$language = App::instance()->language();
|
||||
|
||||
$collectRecursive = function ($page, $currentDepth = 0) use (&$collectRecursive, $maxDepth, $language) {
|
||||
if ($maxDepth !== null && $currentDepth >= $maxDepth) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($page->children() as $child) {
|
||||
if ($this->isIndexable($child)) {
|
||||
$this->urls[] = $child->url($language?->code());
|
||||
}
|
||||
$collectRecursive($child, $currentDepth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
$collectRecursive($this->page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect sibling urls
|
||||
*/
|
||||
protected function collectSiblings(): void
|
||||
{
|
||||
if (!$this->page->parent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$language = App::instance()->language();
|
||||
|
||||
foreach ($this->page->siblings() as $sibling) {
|
||||
if ($this->isIndexable($sibling)) {
|
||||
$this->urls[] = $sibling->url($language?->code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect urls by template names
|
||||
*/
|
||||
protected function collectByTemplates(array $templates): void
|
||||
{
|
||||
$language = App::instance()->language();
|
||||
|
||||
$pages = $this->page->site()->index()
|
||||
->filterBy('intendedTemplate', 'in', $templates)
|
||||
->filter($this->isIndexable(...));
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$this->urls[] = $page->url($language?->code());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a page is indexable (robots allow + listed)
|
||||
*/
|
||||
protected function isIndexable(Page $page): bool
|
||||
{
|
||||
return $page->isListed()
|
||||
&& $page->robots() !== 'noindex'
|
||||
&& $page->robots() !== 'none';
|
||||
}
|
||||
}
|
||||
743
site/plugins/kirby-seo/classes/Meta.php
Normal file
743
site/plugins/kirby-seo/classes/Meta.php
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\FileVersion;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Cms\Language;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function count;
|
||||
use function array_key_exists;
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* The Meta class is responsible for handling the meta data & cascading
|
||||
*/
|
||||
class Meta
|
||||
{
|
||||
/**
|
||||
* These values will be handled as 'field is empty'
|
||||
*/
|
||||
public const DEFAULT_VALUES = ['[]', 'default'];
|
||||
|
||||
protected Page $page;
|
||||
protected ?Language $lang;
|
||||
protected array $consumed = [];
|
||||
protected array $metaDefaults = [];
|
||||
protected array $metaArray = [];
|
||||
|
||||
/**
|
||||
* Creates a new Meta instance
|
||||
*/
|
||||
public function __construct(Page $page, ?Language $lang = null)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->lang = $lang ?? kirby()->language();
|
||||
|
||||
if (method_exists($this->page, 'metaDefaults')) {
|
||||
$this->metaDefaults = $this->page->metaDefaults($this->lang?->code());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a locale string to use a specific separator
|
||||
*
|
||||
* @param string $locale The locale string (e.g., 'en_US.UTF-8', 'en-US', 'en_US')
|
||||
* @param string $separator The separator to use ('-' for BCP47/hreflang, '_' for Open Graph)
|
||||
* @return string The normalized locale (e.g., 'en-US' or 'en_US')
|
||||
*/
|
||||
public static function normalizeLocale(string $locale, string $separator = '-'): string
|
||||
{
|
||||
// encoding suffix if present (e.g., '.UTF-8')
|
||||
$locale = Str::contains($locale, '.') ? Str::before($locale, '.') : $locale;
|
||||
|
||||
// target both underscores and hyphens
|
||||
$locale = Str::replace($locale, '_', $separator);
|
||||
$locale = Str::replace($locale, '-', $separator);
|
||||
|
||||
return $locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Language to BCP 47 language tag format for hreflang attributes
|
||||
*
|
||||
* @param Language $language
|
||||
* @return string The BCP 47 compliant language tag (e.g., 'en-US', 'de-DE')
|
||||
*/
|
||||
public static function toBCP47(Language $language): string
|
||||
{
|
||||
return self::normalizeLocale($language->locale(LC_ALL), '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Language to Open Graph locale format
|
||||
*
|
||||
* @param Language $language
|
||||
* @return string The Open Graph locale format (e.g., 'en_US', 'de_DE')
|
||||
*/
|
||||
public static function toOpenGraphLocale(Language $language): string
|
||||
{
|
||||
return self::normalizeLocale($language->locale(LC_ALL), '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta array which maps meta tags to their fieldnames
|
||||
*/
|
||||
protected function metaArray(): array
|
||||
{
|
||||
if ($this->metaArray) {
|
||||
return $this->metaArray;
|
||||
}
|
||||
|
||||
// We have to specify field names and resolve them later, so we can use this
|
||||
// function to resolve meta tags from field names in the programmatic function
|
||||
$meta =
|
||||
[
|
||||
'title' => 'metaTitle',
|
||||
'description' => 'metaDescription',
|
||||
'date' => fn () => $this->page->modified($this->dateFormat()),
|
||||
'og:title' => 'ogTitle',
|
||||
'og:description' => 'ogDescription',
|
||||
'og:site_name' => 'ogSiteName',
|
||||
'og:image' => 'ogImage',
|
||||
'og:image:width' => fn () => $this->ogImageThumb()?->width() ?? null,
|
||||
'og:image:height' => fn () => $this->ogImageThumb()?->height() ?? null,
|
||||
'og:image:alt' => fn () => $this->get('ogImage')->toFile()?->alt() ?? null,
|
||||
'og:type' => 'ogType',
|
||||
];
|
||||
|
||||
|
||||
// Robots
|
||||
if ($robotsActive = Seo::option('robots.active')) {
|
||||
$meta['robots'] = $this->robots(...);
|
||||
}
|
||||
|
||||
// only add canonical and alternate tags if the page is indexable
|
||||
// we have to resolve this lazily (using a callable) to avoid an infinite loop
|
||||
$allowsIndexFn = fn () => !$robotsActive || !Str::contains($this->robots(), 'noindex');
|
||||
|
||||
// canonical
|
||||
$canonicalFn = fn () => $allowsIndexFn() ? $this->canonicalUrl() : null;
|
||||
$meta['canonical'] = $canonicalFn;
|
||||
$meta['og:url'] = $canonicalFn;
|
||||
|
||||
// Check if the current URL is canonical
|
||||
// Compare the current request URL with the canonical URL
|
||||
$currentUrl = kirby()->request()->url()->toString();
|
||||
$canonicalUrl = $this->canonicalUrl();
|
||||
$isCanonical = $currentUrl === $canonicalUrl;
|
||||
|
||||
// Multi-lang alternate tags
|
||||
// Skip hreflang tags if URL is not canonical (has query params, Kirby params, etc.)
|
||||
if (kirby()->languages()->count() > 1 && $this->lang !== null && $isCanonical) {
|
||||
foreach (kirby()->languages() as $lang) {
|
||||
// only if this language is translated for this page and exists
|
||||
// note: can be checked now, does not cause infinite loop
|
||||
if (!$this->page->translation($lang->code())->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// only add alternate tags if the page is indexable
|
||||
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
|
||||
'hreflang' => Meta::toBCP47($lang),
|
||||
'href' => $this->page->url($lang->code()),
|
||||
'rel' => 'alternate',
|
||||
] : null;
|
||||
|
||||
if ($lang !== $this->lang) {
|
||||
$meta['og:locale:alternate'][] = fn () => Meta::toOpenGraphLocale($lang);
|
||||
}
|
||||
}
|
||||
|
||||
// x-default: language-neutral URL for users whose language doesn't match any translation
|
||||
// indexUrl() strips the language prefix so the server can handle language detection
|
||||
// see config/page-methods.php for details and customization
|
||||
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
|
||||
'hreflang' => 'x-default',
|
||||
'href' => $this->page->indexUrl(),
|
||||
'rel' => 'alternate',
|
||||
] : null;
|
||||
$meta['og:locale'] = fn () => Meta::toOpenGraphLocale($this->lang);
|
||||
} else {
|
||||
// Single-language site: get locale from cascade (will fallback to 'locale' option)
|
||||
$meta['og:locale'] = fn () => Meta::normalizeLocale($this->get('locale')->value(), '_');
|
||||
}
|
||||
|
||||
// If URL is not canonical, also skip og:locale:alternate tags
|
||||
if (!$isCanonical) {
|
||||
unset($meta['og:locale:alternate']);
|
||||
}
|
||||
|
||||
$meta['me'] = fn () => (
|
||||
($socialMedia = $this->site('socialMediaAccounts')?->toObject())
|
||||
&& ($mastodon = $socialMedia->mastodon()->value())
|
||||
) ? $mastodon : null;
|
||||
|
||||
// This array will be normalized for use in the snippet in $this->snippetData()
|
||||
return $this->metaArray = $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* This array defines what HTML tag the corresponding meta tags are using including the attributes,
|
||||
* so everything is a bit more elegant when defining programmatic content (supports regex)
|
||||
*/
|
||||
public const TAG_TYPE_MAP = [
|
||||
[
|
||||
'tag' => 'title',
|
||||
'priority' => true,
|
||||
'tags' => [
|
||||
'title'
|
||||
]
|
||||
],
|
||||
[
|
||||
'tag' => 'link',
|
||||
'attributes' => [
|
||||
'name' => 'rel',
|
||||
'content' => 'href',
|
||||
],
|
||||
'tags' => [
|
||||
'me',
|
||||
'canonical',
|
||||
'alternate',
|
||||
]
|
||||
],
|
||||
[
|
||||
'tag' => 'meta',
|
||||
'attributes' => [
|
||||
'name' => 'property',
|
||||
'content' => 'content',
|
||||
],
|
||||
'tags' => [
|
||||
'/og:.+/'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize the meta array and remaining meta defaults to be used in the snippet,
|
||||
* also resolve the content, if necessary
|
||||
*/
|
||||
public function snippetData(?array $raw = null): array
|
||||
{
|
||||
$mergeWithDefaults = !isset($raw);
|
||||
$raw ??= $this->metaArray();
|
||||
$tags = [];
|
||||
|
||||
foreach ($raw as $name => $value) {
|
||||
// if the key is numeric, it is already normalized to the correct array syntax
|
||||
if (is_numeric($name)) {
|
||||
// but we still check if the array is valid
|
||||
if (!is_array($value) || count(array_intersect(['tag', 'content', 'attributes'], array_keys($value))) !== count($value)) {
|
||||
throw new InvalidArgumentException("[Kirby SEO] Invalid array structure found in programmatic content for page {$this->slug()}. Please check your metaDefaults method for template {$this->template()->name()}.");
|
||||
}
|
||||
|
||||
$tags[] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// allow overrides from metaDefaults for keys that are a callable or array by default
|
||||
// (all fields from meta array that are not part of the regular cascade)
|
||||
if ((is_callable($value) || is_array($value)) && $mergeWithDefaults && array_key_exists($name, $this->metaDefaults)) {
|
||||
$this->consumed[] = $name;
|
||||
$value = $this->metaDefaults[$name];
|
||||
}
|
||||
|
||||
// if the value is a string, we know it's a field name
|
||||
if (is_string($value)) {
|
||||
$value = $this->$value($name);
|
||||
}
|
||||
|
||||
// if the value is a callable, we resolve it
|
||||
if (is_callable($value)) {
|
||||
$value = $value($this->page);
|
||||
}
|
||||
|
||||
// if the value is empty, we don't want to output it
|
||||
if ((is_a($value, Field::class) && $value->isEmpty()) || !$value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// resolve the tag type from the meta array
|
||||
// so we can use the correct attributes to normalize it
|
||||
$tag = $this->resolveTag($name);
|
||||
|
||||
// if the value is an associative array now, all of them are attributes
|
||||
// and we don't look for what the TAG_TYPE_MAP says
|
||||
// or there should be multiple tags with the same name (non-associative array)
|
||||
if (is_array($value)) {
|
||||
if (!A::isAssociative($value)) {
|
||||
foreach ($value as $val) {
|
||||
$tags = array_merge($tags, $this->snippetData([$name => $val]));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// array is associative, so it's an array of attributes
|
||||
// we resolve the values, if they are callable
|
||||
array_walk($value, function (&$item) {
|
||||
if (is_callable($item)) {
|
||||
$item = $item($this->page);
|
||||
}
|
||||
});
|
||||
|
||||
// add the tag to the array
|
||||
$tags[] = [
|
||||
'tag' => $tag['tag'],
|
||||
'attributes' => $value,
|
||||
'content' => null,
|
||||
'priority' => $tag['priority'] ?? false,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the value is a string, we use the TAG_TYPE_MAP
|
||||
// to correctly map the attributes
|
||||
$tags[] = [
|
||||
'tag' => $tag['tag'],
|
||||
'attributes' => isset($tag['attributes']) ? [
|
||||
$tag['attributes']['name'] => $name,
|
||||
$tag['attributes']['content'] => $value,
|
||||
] : null,
|
||||
'content' => !isset($tag['attributes']) ? $value : null,
|
||||
'priority' => $tag['priority'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($mergeWithDefaults) {
|
||||
// merge the remaining meta defaults
|
||||
$tags = array_merge($tags, $this->snippetData(array_diff_key($this->metaDefaults, array_flip($this->consumed))));
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the tag type from the meta array
|
||||
*/
|
||||
protected function resolveTag(string $tag): array
|
||||
{
|
||||
foreach (self::TAG_TYPE_MAP as $type) {
|
||||
foreach ($type['tags'] as $regexOrString) {
|
||||
// Check if the supplied tag is a regex or a normal tag name
|
||||
if (Str::startsWith($regexOrString, '/') && Str::endsWith($regexOrString, '/') ?
|
||||
Str::match($tag, $regexOrString) : $tag === $regexOrString
|
||||
) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'tag' => 'meta',
|
||||
'attributes' => [
|
||||
'name' => 'name',
|
||||
'content' => 'content',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method to get a meta value by calling the method name
|
||||
*/
|
||||
public function __call($name, $args = null): mixed
|
||||
{
|
||||
if (method_exists($this, $name)) {
|
||||
return $this->$name($args);
|
||||
}
|
||||
|
||||
return $this->get($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key
|
||||
*/
|
||||
public function get(string $key, array $exclude = []): Field
|
||||
{
|
||||
$cascade = Seo::option('cascade');
|
||||
if (count(array_intersect(get_class_methods($this), $cascade)) !== count($cascade)) {
|
||||
throw new InvalidArgumentException('[Kirby SEO] Invalid cascade method in config. Please check your options for `tobimori.seo.cascade`.');
|
||||
}
|
||||
|
||||
// Track consumed keys, so we don't output legacy field values
|
||||
$toBeConsumed = $key;
|
||||
if (
|
||||
(array_key_exists($toBeConsumed, $this->metaDefaults)
|
||||
|| array_key_exists($toBeConsumed = $this->findTagForField($toBeConsumed), $this->metaDefaults))
|
||||
&& !in_array($toBeConsumed, $this->consumed)
|
||||
) {
|
||||
$this->consumed[] = $toBeConsumed;
|
||||
}
|
||||
|
||||
foreach (array_diff($cascade, $exclude) as $method) {
|
||||
if ($field = $this->$method($key)) {
|
||||
if (
|
||||
is_string($value = $field->value())
|
||||
&& Str::contains($value, 'data-seo-template-variable')
|
||||
) {
|
||||
$value = Str::unhtml($value);
|
||||
return new Field($this->page, $key, $value);
|
||||
}
|
||||
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
return new Field($this->page, $key, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key from the page's fields
|
||||
*/
|
||||
protected function fields(string $key): Field|null
|
||||
{
|
||||
if (($field = $this->page->content($this->lang?->code())->get($key))) {
|
||||
if (Str::contains($key, 'robots') && !Seo::option('robots.pageSettings')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($field->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $field->value())) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Open Graph fields to Meta fields for fallbackFields
|
||||
* cascade method
|
||||
*/
|
||||
public const FALLBACK_MAP = [
|
||||
'ogTitle' => 'metaTitle',
|
||||
'ogDescription' => 'metaDescription',
|
||||
'ogTemplate' => 'metaTemplate',
|
||||
];
|
||||
|
||||
/**
|
||||
* We only allow the following cascade methods for fallbacks,
|
||||
* because we don't want to fallback to the config defaults for
|
||||
* Meta fields, because we most likely already have those set
|
||||
* for the Open Graph fields
|
||||
*/
|
||||
public const FALLBACK_CASCADE = [
|
||||
'fields',
|
||||
'programmatic',
|
||||
'parent',
|
||||
'site'
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key using the fallback fields
|
||||
* defined above (usually Open Graph > Meta Fields)
|
||||
*/
|
||||
protected function fallbackFields(string $key): Field|null
|
||||
{
|
||||
if (array_key_exists($key, self::FALLBACK_MAP)) {
|
||||
$fallback = self::FALLBACK_MAP[$key];
|
||||
$cascade = Seo::option('cascade');
|
||||
|
||||
foreach (array_intersect($cascade, self::FALLBACK_CASCADE) as $method) {
|
||||
if ($field = $this->$method($fallback)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function findTagForField(string $fieldName): string|null
|
||||
{
|
||||
return array_search($fieldName, $this->metaArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key from the page's meta
|
||||
* array, which can be set in the page's model metaDefaults method
|
||||
*/
|
||||
protected function programmatic(string $key): Field|null
|
||||
{
|
||||
if (!$this->metaDefaults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the key (field name) is in the array syntax
|
||||
if (array_key_exists($key, $this->metaDefaults)) {
|
||||
$val = $this->metaDefaults[$key];
|
||||
}
|
||||
|
||||
// If there is no programmatic value for the key,
|
||||
// try looking it up in the meta array
|
||||
// maybe it is a meta tag and not a field name?
|
||||
if (!isset($val) && ($key = $this->findTagForField($key)) && array_key_exists($key, $this->metaDefaults)) {
|
||||
$val = $this->metaDefaults[$key];
|
||||
}
|
||||
|
||||
if (isset($val)) {
|
||||
if (is_callable($val)) {
|
||||
$val = $val($this->page);
|
||||
}
|
||||
|
||||
if (is_array($val)) {
|
||||
$val = $val['content'] ?? $val['href'] ?? null;
|
||||
|
||||
// Last sanity check, if the array syntax doesn't have a supported key
|
||||
if ($val === null) {
|
||||
// Remove the key from the consumed array, so it doesn't get filtered out
|
||||
// (we can assume the entry is a custom meta tag that uses different attributes)
|
||||
$this->consumed = array_filter($this->consumed, fn ($item) => $item !== $key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_a($val, 'Kirby\Content\Field')) {
|
||||
return new Field($this->page, $key, $val->value());
|
||||
}
|
||||
|
||||
return new Field($this->page, $key, $val);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key from the page's parent,
|
||||
* if the page is allowed to inherit the value
|
||||
*/
|
||||
protected function parent(string $key): Field|null
|
||||
{
|
||||
if ($this->canInherit($key)) {
|
||||
$parent = $this->page->parent();
|
||||
$parentMeta = new Meta($parent, $this->lang);
|
||||
if ($value = $parentMeta->get($key)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key from the
|
||||
* site's meta blueprint & content
|
||||
*/
|
||||
protected function site(string $key): Field|null
|
||||
{
|
||||
if (($site = $this->page->site()->content($this->lang?->code())->get($key)) && ($site->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $site->value))) {
|
||||
return $site;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the meta value for a given key from the
|
||||
* config.php options
|
||||
*/
|
||||
protected function options(string $key): Field|null
|
||||
{
|
||||
if ($option = Seo::option("default.{$key}", args: [$this->page])) {
|
||||
if (is_a($option, Field::class)) {
|
||||
return $option;
|
||||
}
|
||||
|
||||
return new Field($this->page, $key, $option);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page can inherit a meta value from its parent
|
||||
*/
|
||||
private function canInherit(string $key): bool
|
||||
{
|
||||
$parent = $this->page->parent();
|
||||
if (!$parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$inherit = $parent->metaInherit()->split();
|
||||
if (Str::contains($key, 'robots') && A::has($inherit, 'robots')) {
|
||||
return true;
|
||||
}
|
||||
return A::has($inherit, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the title template, and returns the correct title
|
||||
*/
|
||||
public function metaTitle()
|
||||
{
|
||||
$title = $this->get('metaTitle');
|
||||
$template = $this->get('metaTemplate');
|
||||
|
||||
$useTemplate = $this->page->useTitleTemplate();
|
||||
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
|
||||
|
||||
$string = $title->value();
|
||||
if ($useTemplate) {
|
||||
$string = $this->page->toString(
|
||||
$template,
|
||||
['title' => $title]
|
||||
);
|
||||
}
|
||||
|
||||
return new Field(
|
||||
$this->page,
|
||||
'metaTitle',
|
||||
$string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the OG title template, and returns the OG Title
|
||||
*/
|
||||
public function ogTitle()
|
||||
{
|
||||
$title = $this->get('metaTitle');
|
||||
$template = $this->get('ogTemplate');
|
||||
|
||||
$useTemplate = $this->page->useOgTemplate();
|
||||
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
|
||||
|
||||
$string = $title->value();
|
||||
if ($useTemplate) {
|
||||
$string = $this->page->toString(
|
||||
$template,
|
||||
['title' => $title]
|
||||
);
|
||||
}
|
||||
|
||||
return new Field(
|
||||
$this->page,
|
||||
'ogTitle',
|
||||
$string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the canonical url for the page
|
||||
*/
|
||||
public function canonicalUrl()
|
||||
{
|
||||
return $this->page->site()->canonicalFor($this->page->url());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the date format for modified meta tags, based on the registered date handler
|
||||
*/
|
||||
public function dateFormat(): string
|
||||
{
|
||||
if ($custom = Seo::option('dateFormat')) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
switch (option('date.handler')) {
|
||||
case 'strftime':
|
||||
return '%Y-%m-%d';
|
||||
case 'intl':
|
||||
return 'yyyy-MM-dd';
|
||||
case 'date':
|
||||
default:
|
||||
return 'Y-m-d';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pages' robots rules as string
|
||||
*/
|
||||
public function robots()
|
||||
{
|
||||
$robots = [];
|
||||
foreach (Seo::option('robots.types') as $type) {
|
||||
if (!$this->get('robots' . Str::ucfirst($type))->toBool()) {
|
||||
$robots[] = 'no' . Str::lower($type);
|
||||
}
|
||||
}
|
||||
|
||||
if (A::count($robots) === 0) {
|
||||
$robots = ['all'];
|
||||
}
|
||||
|
||||
return A::join($robots, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:image thumb object
|
||||
*/
|
||||
public function ogImageThumb(): FileVersion|null
|
||||
{
|
||||
$field = $this->get('ogImage');
|
||||
|
||||
// Only process if we have a file object
|
||||
if ($file = $field->toFile()) {
|
||||
$cropOgImage = $this->get('cropOgImage')->toBool();
|
||||
|
||||
if ($cropOgImage) {
|
||||
// Crop to 1200x630
|
||||
return $file->thumb([
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'crop' => true,
|
||||
]);
|
||||
} else {
|
||||
// Resize to max 1500px on the longest side
|
||||
return $file->thumb([
|
||||
'width' => 1500,
|
||||
'height' => 1500,
|
||||
'upscale' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if it's a custom URL or empty
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:image url
|
||||
*/
|
||||
public function ogImage(): string|null
|
||||
{
|
||||
if ($ogImage = $this->ogImageThumb()) {
|
||||
return $ogImage->url();
|
||||
}
|
||||
|
||||
$field = $this->get('ogImage');
|
||||
if ($field->isNotEmpty()) {
|
||||
return $field->value();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method the get the current page from the URL path,
|
||||
* for use in programmatic blueprints
|
||||
*/
|
||||
public static function currentPage(): Page|null
|
||||
{
|
||||
$path = App::instance()->request()->url()->toString();
|
||||
$matches = Str::match($path, "/pages\/([a-zA-Z0-9-_+]+)\/?/m");
|
||||
$segments = Str::split($matches[1], '+');
|
||||
|
||||
$page = App::instance()->site();
|
||||
foreach ($segments as $segment) {
|
||||
if ($page = $page->findPageOrDraft($segment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
37
site/plugins/kirby-seo/classes/SchemaSingleton.php
Normal file
37
site/plugins/kirby-seo/classes/SchemaSingleton.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Spatie\SchemaOrg\Schema;
|
||||
|
||||
class SchemaSingleton
|
||||
{
|
||||
private static $instances = [];
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function getInstance(string $type, Page|null $page = null): mixed
|
||||
{
|
||||
if (!class_exists('Spatie\SchemaOrg\Schema')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset(self::$instances[$page?->id() ?? 'default'][$type])) {
|
||||
self::$instances[$page?->id() ?? 'default'][$type] = Schema::{$type}();
|
||||
}
|
||||
|
||||
return self::$instances[$page?->id() ?? 'default'][$type];
|
||||
}
|
||||
|
||||
public static function getInstances(Page|null $page = null): array
|
||||
{
|
||||
if (!class_exists('Spatie\SchemaOrg\Schema')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::$instances[$page?->id() ?? 'default'] ?? [];
|
||||
}
|
||||
}
|
||||
29
site/plugins/kirby-seo/classes/Seo.php
Normal file
29
site/plugins/kirby-seo/classes/Seo.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
|
||||
final class Seo
|
||||
{
|
||||
/**
|
||||
* Returns the user agent string for the plugin
|
||||
*/
|
||||
public static function userAgent(): string
|
||||
{
|
||||
return "Kirby SEO/" . App::plugin('tobimori/seo')->version() . " (+https://plugins.andkindness.com/seo)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plugin option
|
||||
*/
|
||||
public static function option(string $key, mixed $default = null, mixed $args = []): mixed
|
||||
{
|
||||
$option = App::instance()->option("tobimori.seo.{$key}", $default);
|
||||
if (is_callable($option)) {
|
||||
$option = $option(...$args);
|
||||
}
|
||||
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
87
site/plugins/kirby-seo/classes/Sitemap/Sitemap.php
Normal file
87
site/plugins/kirby-seo/classes/Sitemap/Sitemap.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Sitemap;
|
||||
|
||||
use DOMDocument;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\Collection;
|
||||
|
||||
class Sitemap extends Collection
|
||||
{
|
||||
public function __construct(protected string $key, array $data = [], bool $caseSensitive = false)
|
||||
{
|
||||
parent::__construct($data, $caseSensitive);
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function loc(): string
|
||||
{
|
||||
return kirby()->site()->canonicalFor('sitemap-' . $this->key . '.xml');
|
||||
}
|
||||
|
||||
public function lastmod(): string
|
||||
{
|
||||
$lastmod = 0;
|
||||
foreach ($this as $url) {
|
||||
$lastmod = max($lastmod, strtotime($url->lastmod()));
|
||||
}
|
||||
|
||||
if ($lastmod > 0) {
|
||||
return date('c', $lastmod);
|
||||
}
|
||||
|
||||
return date('c');
|
||||
}
|
||||
|
||||
public function createUrl(string $loc): SitemapUrl
|
||||
{
|
||||
$url = $this->makeUrl($loc);
|
||||
$this->append($url);
|
||||
return $url;
|
||||
}
|
||||
|
||||
public static function makeUrl(string $url): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl($url);
|
||||
}
|
||||
|
||||
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8'))
|
||||
{
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$root = $doc->createElement('sitemap');
|
||||
foreach (['loc', 'lastmod'] as $key) {
|
||||
$root->appendChild($doc->createElement($key, $this->$key()));
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$stylesheetUrl = kirby()->site()->canonicalFor("sitemap.xsl", true);
|
||||
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="' . $stylesheetUrl . '"'));
|
||||
|
||||
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'urlset');
|
||||
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
|
||||
|
||||
// version can be null when installing branches during development
|
||||
if ($version = App::plugin('tobimori/seo')->version()) {
|
||||
$root->setAttribute('seo-version', $version);
|
||||
}
|
||||
|
||||
foreach ($this as $url) {
|
||||
$root->appendChild($url->toDOMNode($doc));
|
||||
}
|
||||
|
||||
$doc->appendChild($root);
|
||||
return $doc->saveXML();
|
||||
}
|
||||
}
|
||||
101
site/plugins/kirby-seo/classes/Sitemap/SitemapIndex.php
Normal file
101
site/plugins/kirby-seo/classes/Sitemap/SitemapIndex.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Sitemap;
|
||||
|
||||
use DOMDocument;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Toolkit\Collection;
|
||||
|
||||
class SitemapIndex extends Collection
|
||||
{
|
||||
protected static $instance = null;
|
||||
|
||||
public static function instance(...$args): static
|
||||
{
|
||||
if (static::$instance === null) {
|
||||
static::$instance = new static(...$args);
|
||||
}
|
||||
|
||||
return static::$instance;
|
||||
}
|
||||
|
||||
public function create(string $key = 'pages'): Sitemap
|
||||
{
|
||||
$sitemap = $this->make($key);
|
||||
$this->append($sitemap);
|
||||
return $sitemap;
|
||||
}
|
||||
|
||||
public static function make(string $key = 'pages'): Sitemap
|
||||
{
|
||||
return new Sitemap($key);
|
||||
}
|
||||
|
||||
public static function makeUrl(string $url): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl($url);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$stylesheetUrl = kirby()->site()->canonicalFor("sitemap.xsl", true);
|
||||
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="' . $stylesheetUrl . '"'));
|
||||
|
||||
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'sitemapindex');
|
||||
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
|
||||
$root->setAttribute('seo-version', App::plugin('tobimori/seo')->version());
|
||||
$doc->appendChild($root);
|
||||
|
||||
foreach ($this as $sitemap) {
|
||||
$root->appendChild($sitemap->toDOMNode($doc));
|
||||
}
|
||||
|
||||
return $doc->saveXML();
|
||||
}
|
||||
|
||||
public function isValidIndex(?string $key = null): bool
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->count() > 1;
|
||||
}
|
||||
|
||||
return !!$this->findBy('key', $key) && $this->count() > 1;
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$generator = option('tobimori.seo.sitemap.generator');
|
||||
if (is_callable($generator)) {
|
||||
$generator($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(Page $page): string|null
|
||||
{
|
||||
// There always has to be at least one index,
|
||||
// otherwise the sitemap will fail to render
|
||||
if ($this->count() === 0) {
|
||||
$this->generate();
|
||||
}
|
||||
|
||||
if ($this->count() === 0) {
|
||||
$this->create();
|
||||
}
|
||||
|
||||
if (($index = $page->content()->get('index'))->isEmpty()) {
|
||||
// If there is only one index, we do not need to render the index page
|
||||
return $this->count() > 1 ? $this->toString() : $this->first()->toString();
|
||||
}
|
||||
|
||||
$sitemap = $this->findBy('key', $index->value());
|
||||
if ($sitemap) {
|
||||
return $sitemap->toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
115
site/plugins/kirby-seo/classes/Sitemap/SitemapUrl.php
Normal file
115
site/plugins/kirby-seo/classes/Sitemap/SitemapUrl.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Sitemap;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMNode;
|
||||
use Kirby\Exception\Exception;
|
||||
|
||||
use function array_key_exists;
|
||||
|
||||
class SitemapUrl
|
||||
{
|
||||
protected string $lastmod;
|
||||
protected string $changefreq;
|
||||
protected string $priority;
|
||||
protected array $alternates = [];
|
||||
|
||||
public function __construct(protected string $loc)
|
||||
{
|
||||
}
|
||||
|
||||
public function loc(?string $url = null): SitemapUrl|string
|
||||
{
|
||||
if ($url === null) {
|
||||
return $this->loc;
|
||||
}
|
||||
|
||||
$this->loc = $url;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function lastmod(?string $lastmod = null): SitemapUrl|string
|
||||
{
|
||||
if ($lastmod === null) {
|
||||
return $this->lastmod;
|
||||
}
|
||||
|
||||
$this->lastmod = date('c', $lastmod);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function changefreq(?string $changefreq = null): SitemapUrl|string
|
||||
{
|
||||
if ($changefreq === null) {
|
||||
return $this->changefreq;
|
||||
}
|
||||
|
||||
$this->changefreq = $changefreq;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function priority(?string $priority = null): SitemapUrl|string
|
||||
{
|
||||
if ($priority === null) {
|
||||
return $this->priority;
|
||||
}
|
||||
|
||||
$this->priority = $priority;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function alternates(array $alternates = []): SitemapUrl|array
|
||||
{
|
||||
if (empty($alternates)) {
|
||||
return $this->alternates;
|
||||
}
|
||||
|
||||
foreach ($alternates as $alternate) {
|
||||
foreach (['href', 'hreflang'] as $key) {
|
||||
if (!array_key_exists($key, $alternate)) {
|
||||
new Exception("[Kirby SEO] The alternate link to '{$this->loc()} is missing the '{$key}' attribute");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->alternates = $alternates;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8')): DOMNode
|
||||
{
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$node = $doc->createElement('url');
|
||||
|
||||
foreach (array_diff_key(get_object_vars($this), array_flip(['alternates'])) as $key => $value) {
|
||||
$node->appendChild($doc->createElement($key, $value));
|
||||
}
|
||||
|
||||
if (!empty($this->alternates())) {
|
||||
foreach ($this->alternates() as $alternate) {
|
||||
$alternateNode = $doc->createElement('xhtml:link');
|
||||
foreach ($alternate as $key => $value) {
|
||||
$alternateNode->setAttribute($key, $value);
|
||||
}
|
||||
|
||||
$node->appendChild($alternateNode);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$node = $this->toDOMNode();
|
||||
$doc->appendChild($node);
|
||||
|
||||
return $doc->saveXML($node);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue