feat: intégration plugin Kirby SEO
All checks were successful
Deploy / Deploy to Production (push) Successful in 22s

- Ajout de tobimori/kirby-seo via Composer
- snippet('seo/head') dans header.php (remplace les meta manuels)
- snippet('seo/schemas') dans footer.php pour JSON-LD
- Onglet SEO ajouté dans site.yml et tous les blueprints de pages
- Configuration SEO dans config.php (sitemap, robots, canonicalBase TODO)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-25 12:59:18 +01:00
parent baab2fb3a1
commit 58c31ea391
133 changed files with 9201 additions and 253 deletions

View 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;
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace tobimori\Seo\Ai;
use Imagick;
use Kirby\Cms\File;
/**
* Fluent builder for AI message content.
* Each instance represents a single message with a role and content blocks.
*/
class Content
{
private string $role;
private array $blocks = [];
private function __construct(string $role)
{
$this->role = $role;
}
public static function user(): static
{
return new static('user');
}
public static function assistant(): static
{
return new static('assistant');
}
public static function system(): static
{
return new static('system');
}
public function text(string $text): static
{
$this->blocks[] = ['type' => 'text', 'text' => $text];
return $this;
}
/**
* Adds an image block from a Kirby File, converted to WebP for smaller payloads.
* Non-resizable formats (SVG, etc.) are rasterized via Imagick.
*/
public function image(File $file, int $maxDimension = 1024): static
{
if ($file->isResizable()) {
$thumb = $file->thumb([
'width' => $maxDimension,
'height' => $maxDimension,
'format' => 'webp',
]);
$data = base64_encode($thumb->read());
} else {
// TODO: better handling without ext-imagick
$imagick = new Imagick();
$imagick->readImage($file->root());
$imagick->setImageFormat('webp');
$imagick->thumbnailImage($maxDimension, $maxDimension, true);
$data = base64_encode($imagick->getImageBlob());
$imagick->clear();
$imagick->destroy();
}
$this->blocks[] = [
'type' => 'image',
'data' => $data,
'mediaType' => 'image/webp',
];
return $this;
}
public function role(): string
{
return $this->role;
}
public function blocks(): array
{
return $this->blocks;
}
}

View 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 content.
*
* @param array<Content> $content Array of Content messages forming a conversation.
* @param string|null $model Model override.
*
* @return Generator<int, Chunk, mixed, void>
*/
abstract public function stream(array $content, 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;
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace tobimori\Seo\Ai\Drivers;
use Generator;
use tobimori\Seo\Ai\Chunk;
use tobimori\Seo\Ai\Content;
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(
array $content,
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' => $this->buildMessages($content),
'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;
}
});
}
/**
* Translates an array of Content messages into the Anthropic messages format.
*
* @param array<Content> $content
*/
private function buildMessages(array $content): array
{
$messages = [];
foreach ($content as $message) {
$blocks = [];
foreach ($message->blocks() as $block) {
if ($block['type'] === 'image') {
$blocks[] = [
'type' => 'image',
'source' => [
'type' => 'base64',
'media_type' => $block['mediaType'],
'data' => $block['data'],
],
];
} elseif ($block['type'] === 'text') {
$blocks[] = [
'type' => 'text',
'text' => $block['text'],
];
}
}
$messages[] = [
'role' => $message->role(),
'content' => $blocks,
];
}
return $messages;
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace tobimori\Seo\Ai\Drivers;
use Generator;
use tobimori\Seo\Ai\Chunk;
use tobimori\Seo\Ai\Content;
use tobimori\Seo\Ai\Driver;
use tobimori\Seo\Ai\SseStream;
class Gemini extends Driver
{
protected const string DEFAULT_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta';
protected const string DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview';
/**
* @inheritDoc
*/
public function stream(
array $content,
string|null $model = null,
): Generator {
$apiKey = $this->config('apiKey', required: true);
$model = $model ?? $this->config('model', static::DEFAULT_MODEL);
$baseEndpoint = $this->config('endpoint', static::DEFAULT_ENDPOINT);
$endpoint = "{$baseEndpoint}/models/{$model}:streamGenerateContent?alt=sse&key={$apiKey}";
$headers = [
'Content-Type: application/json',
];
$payload = [
'contents' => $this->buildContents($content),
];
$systemInstruction = $this->buildSystemInstruction($content);
if ($systemInstruction !== null) {
$payload['systemInstruction'] = $systemInstruction;
}
$stream = new SseStream($endpoint, $headers, $payload, (int)$this->config('timeout', 120));
$started = false;
yield from $stream->stream(function (array $event) use (&$started): Generator {
$candidates = $event['candidates'] ?? [];
$candidate = $candidates[0] ?? null;
if ($candidate === null) {
$error = $event['error'] ?? null;
if ($error) {
yield Chunk::error($error['message'] ?? 'Unknown Gemini error.', $event);
}
return;
}
if (!$started) {
yield Chunk::streamStart($event);
yield Chunk::textStart($event);
$started = true;
}
$finishReason = $candidate['finishReason'] ?? null;
if ($finishReason === 'SAFETY') {
yield Chunk::error('Response blocked by safety filters.', $event);
return;
}
$parts = $candidate['content']['parts'] ?? [];
foreach ($parts as $part) {
$text = $part['text'] ?? '';
if ($text !== '') {
yield Chunk::textDelta($text, $event);
}
}
if ($finishReason !== null) {
yield Chunk::textComplete($event);
yield Chunk::streamEnd($event);
}
});
}
/**
* Translates an array of Content messages into the Gemini contents format.
*
* @param array<Content> $content
*/
private function buildContents(array $content): array
{
$contents = [];
foreach ($content as $message) {
if ($message->role() === 'system') {
continue;
}
$parts = [];
foreach ($message->blocks() as $block) {
if ($block['type'] === 'image') {
$parts[] = [
'inline_data' => [
'mime_type' => $block['mediaType'],
'data' => $block['data'],
],
];
} elseif ($block['type'] === 'text') {
$parts[] = [
'text' => $block['text'],
];
}
}
$contents[] = [
'role' => $message->role() === 'assistant' ? 'model' : 'user',
'parts' => $parts,
];
}
return $contents;
}
/**
* Extracts system messages into a Gemini systemInstruction object.
*
* @param array<Content> $content
*/
private function buildSystemInstruction(array $content): array|null
{
$parts = [];
foreach ($content as $message) {
if ($message->role() !== 'system') {
continue;
}
foreach ($message->blocks() as $block) {
if ($block['type'] === 'text') {
$parts[] = ['text' => $block['text']];
}
}
}
if ($parts === []) {
return null;
}
return ['parts' => $parts];
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace tobimori\Seo\Ai\Drivers;
use Generator;
use tobimori\Seo\Ai\Chunk;
use tobimori\Seo\Ai\Content;
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(
array $content,
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' => $this->buildInput($content),
// 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);
}
});
}
/**
* Translates an array of Content messages into the OpenAI Responses API input format.
*
* @param array<Content> $content
*/
private function buildInput(array $content): array
{
$input = [];
foreach ($content as $message) {
$blocks = [];
foreach ($message->blocks() as $block) {
if ($block['type'] === 'image') {
$blocks[] = [
'type' => 'input_image',
'image_url' => "data:{$block['mediaType']};base64,{$block['data']}",
];
} elseif ($block['type'] === 'text') {
$blocks[] = [
'type' => 'input_text',
'text' => $block['text'],
];
}
}
$input[] = [
'role' => $message->role(),
'content' => $blocks,
];
}
return $input;
}
}

View file

@ -0,0 +1,194 @@
<?php
namespace tobimori\Seo\Ai;
use Generator;
use Kirby\Exception\Exception as KirbyException;
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);
}
}
/**
* @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);
}
}