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,27 @@
<?php
/**
* @var \Kirby\Cms\Page $page
*/
use Kirby\Cms\Html;
$tags = $page->metadata()->snippetData();
// if we're using slots, the user wants to output priority tags such as <title>
// before their stylesheet, script, etc. tags
if (isset($slot)) {
foreach (array_filter($tags, fn ($tag) => $tag['priority']) as $tag) {
echo Html::tag($tag['tag'], $tag['content'] ?? null, $tag['attributes'] ?? []) . PHP_EOL;
}
echo $slot;
$tags = array_filter($tags, fn ($tag) => !$tag['priority']);
}
// then output other tags as normal
// this is unfiltered if slots is not set.
foreach ($tags as $tag) {
echo Html::tag($tag['tag'], $tag['content'] ?? null, $tag['attributes'] ?? []) . PHP_EOL;
}

View file

@ -0,0 +1,114 @@
<?php
use Kirby\Toolkit\Str;
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site */
$contentHtml = $page->render();
if ($contentHtml !== '' && class_exists('DOMDocument')) {
$dom = new \DOMDocument('1.0', 'UTF-8');
$previousLibxmlState = libxml_use_internal_errors(true);
$encoded = mb_encode_numericentity($contentHtml, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
$loaded = $dom->loadHTML('<?xml encoding="UTF-8"?>' . $encoded, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET);
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlState);
if ($loaded !== false) {
foreach (
[
'script',
'style',
'noscript',
'template',
'svg',
'canvas',
'iframe',
'video',
'audio',
'object',
'embed',
'source',
'track',
'nav',
'footer',
'aside',
'form',
'button',
'input',
'select',
'textarea',
'label',
'menu',
'header',
] as $tag
) {
$nodes = $dom->getElementsByTagName($tag);
for ($i = $nodes->length - 1; $i >= 0; $i--) {
$node = $nodes->item($i);
if ($node !== null && $node->parentNode !== null) {
$node->parentNode->removeChild($node);
}
}
}
$xpath = new \DOMXPath($dom);
foreach (
[
'navigation',
'banner',
'contentinfo',
'complementary',
'search',
'menu',
'menubar',
'toolbar',
] as $role
) {
$nodes = $xpath->query("//*[@role='{$role}']");
if ($nodes === false) {
continue;
}
foreach ($nodes as $node) {
if ($node->parentNode !== null) {
$node->parentNode->removeChild($node);
}
}
}
$body = $dom->getElementsByTagName('body')->item(0) ?? $dom->documentElement;
if ($body instanceof \DOMNode) {
$innerHtml = '';
foreach ($body->childNodes as $child) {
$innerHtml .= $dom->saveHTML($child);
}
if ($innerHtml !== '') {
$contentHtml = $innerHtml;
}
}
}
}
$blockClosingPattern = 'p|div|section|article|main|aside|header|footer|li|ul|ol|dl|blockquote|pre|figure|figcaption|h[1-6]|table|thead|tbody|tfoot|tr|td|th|dd|dt';
$contentHtml = preg_replace('~<(?:br|hr)\b[^>]*?>~i', "\n", $contentHtml);
$contentHtml = preg_replace('~</(?:' . $blockClosingPattern . ')>~i', "\n", $contentHtml);
$text = Str::unhtml($contentHtml);
$text = Str::replace($text, "\r", "\n");
$text = preg_replace("/[ \t\x{00A0}\x{202F}\x{2007}\x{2060}]+/u", ' ', $text);
$text = preg_replace("/ *\n+ */", "\n", $text);
$text = preg_replace("/\n{3,}/", "\n\n", $text);
$content = trim($text);
?>
<content>
<?= htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
</content>

View file

@ -0,0 +1,46 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */ ?>
<role>
You are a professional SEO copywriter for <?= $site->title() ?>. Create high-quality content. Mimic the site's tone and style. You'll be rewarded based on the conversion rate.
</role>
<rules>
- You MUST only output the answer without additional prose or introduction.
- You MUST mimic the site's tone and style. DO NOT shift register (informal stays informal).
- The output language MUST be <language><?= $site->lang() ?></language>. Translate the content into <?= $site->lang() ?>.
- ALWAYS and ONLY provide exactly one answer. DO NOT suggest multiple answers.
- NEVER output any formatting. No new lines, no HTML tags, no quotes, no markdown.
- NEVER output or introduce information that is not provided in the content.
- NEVER output duplicate content in the same answer.
</rules>
<?php if (isset($edit) && $edit !== null && $edit !== '') : ?>
<primary-editing-task>
YOU ARE EDITING EXISTING CONTENT - NOT CREATING NEW CONTENT.
Current text that needs editing:
<current-value><?= $edit ?></current-value>
CRITICAL EDITING RULES:
- Start from the text above and modify ONLY what is requested
- Preserve as much of the original as possible
- Keep the same style, tone, and structure
- Change ONLY the specific parts mentioned in the instructions below
</primary-editing-task>
<?php endif ?>
<?php if (isset($instructions) && $instructions !== null && $instructions !== '') : ?>
<user-instructions>
<?php if (isset($edit) && $edit !== null && $edit !== '') : ?>
Apply ONLY these changes to the text above:
<?php else : ?>
The user has provided these specific instructions:
<?php endif ?>
<?= $instructions ?>
</user-instructions>
<?php endif ?>

View file

@ -0,0 +1,28 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $currentField */
$meta = $page->metadata();
$currentField = $currentField ?? null;
$metaFields = [
'metaTitle' => 'Meta Title',
'metaDescription' => 'Meta Description',
'ogTitle' => 'Open Graph Title',
'ogDescription' => 'Open Graph Description'
];
?>
<existing-metadata>
<?php foreach ($metaFields as $key => $label) : ?>
<?php
$value = $meta->get($key);
if ($currentField === $key || !$value || $value === '') {
continue;
}
?>
<<?= $key ?>><?= $value ?></<?= $key ?>>
<?php endforeach ?>
</existing-metadata>

View file

@ -0,0 +1,25 @@
<?php
/** @var \Kirby\Cms\Site $site
** @var string|null $currentField */
$currentField = $currentField ?? null;
$metaFields = [
'metaDescription' => 'Site Meta Description',
'ogDescription' => 'Site Open Graph Description',
'ogSiteName' => 'Site Name'
];
?>
<existing-site-metadata>
<?php foreach ($metaFields as $key => $label) : ?>
<?php
$value = $site->$key()->value();
if ($currentField === $key || !$value || $value === '') {
continue;
}
?>
<<?= $key ?>><?= $value ?></<?= $key ?>>
<?php endforeach ?>
</existing-site-metadata>

View file

@ -0,0 +1,52 @@
<?php
/** @var \Kirby\Cms\File $file
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var array<string>|null $languages */ ?>
<role>
You are an accessibility expert writing alt text for images on <?= $site->title() ?>.
</role>
<rules>
- Be brief. One short sentence is ideal. Two sentences maximum, only if truly needed.
- Start directly with the subject NO introductory phrases like "Image of", "Photo of", "Shows", "Displays", "Depicts", "Contains", "Features", or similar prefixes.
- Describe the meaning and purpose, not visual details. For example, "Logo of Mastercard" instead of "Two overlapping circles in orange and red".
- DO NOT enumerate individual UI elements, icons, colors, or positions. Focus on the overall subject.
- Do not add a trailing period to short noun phrases. Only use periods for full sentences.
- NEVER output any formatting. No HTML tags, no quotes, no markdown.
<?php if (!empty($languages)) : ?>
- Output one line per language in the format <langcode>: <alt text>
- Do NOT translate proper nouns, brand names, or technical terms.
- Output ONLY the language lines. No additional text.
<?php else : ?>
- You MUST only output the alt text without additional prose, quotes, or introduction.
- The output language MUST be <language><?= $site->lang() ?></language>.
<?php endif ?>
</rules>
<?php if (isset($instructions) && $instructions !== null && $instructions !== '') : ?>
<user-instructions>
<?= $instructions ?>
</user-instructions>
<?php endif ?>
<task>
Write descriptive alt text for the attached image.
The file is named <filename><?= $file->filename() ?></filename>.
<?php if ($file->parent() instanceof \Kirby\Cms\Page) : ?>
The image is on a page called <page-title><?= $file->parent()->title() ?></page-title>.
<?php endif ?>
<?php if ($file->template()) : ?>
This image uses the file template <template><?= $file->template() ?></template>.
<?php endif ?>
<?php if (!empty($languages)) : ?>
Output alt text in these languages: <?= implode(', ', $languages) ?>
<?php endif ?>
</task>

View file

@ -0,0 +1,24 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */
$meta = $page->metadata();
snippet('seo/prompts/introduction', [
'instructions' => $instructions ?? null,
'edit' => $edit ?? null
]); ?>
<task>
Create a useful meta description for this page called <page-title><?= $page->title()->value() ?></page-title>. <?php if ($page->isHomePage()) : ?>This page is the homepage of the website.<?php endif ?>
The entire meta description SHOULD be between 120 and 158 characters long.
You'll receive the content of the page as well as any meta tags that are already set below.
</task>
<?php snippet('seo/prompts/meta', ['currentField' => 'metaDescription']);
snippet('seo/prompts/content');

View file

@ -0,0 +1,25 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */
$meta = $page->metadata();
snippet('seo/prompts/introduction', [
'instructions' => $instructions ?? null,
'edit' => $edit ?? null
]); ?>
<task>
Create a useful open graph description for this page called <page-title><?= $page->title()->value() ?></page-title>. <?php if ($page->isHomePage()) : ?>This page is the homepage of the website.<?php endif ?>
This description will be shown on social media platforms like Facebook, WhatsApp and LinkedIn.
The entire meta description SHOULD be between 120 and 158 characters long.
You'll receive the content of the page as well as any meta tags that are already set below.
</task>
<?php snippet('seo/prompts/meta', ['currentField' => 'ogDescription']);
snippet('seo/prompts/content');

View file

@ -0,0 +1,25 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */
snippet('seo/prompts/introduction', [
'instructions' => $instructions ?? null,
'edit' => $edit ?? null
]); ?>
<task>
Create a useful GLOBAL open graph description for this site <site-title><?= $site->title()->value() ?>.</site-title>
This description will be shown on social media platforms like Facebook, WhatsApp and LinkedIn.
This description is meant as FALLBACK for when the page does not have a meta description itself.
This description should be unique and relevant to the site's content.
The entire meta description SHOULD be between 120 and 158 characters long.
You'll receive the content of the home page as well as any meta tags that are already set below.
</task>
<?php snippet('seo/prompts/site-meta', ['currentField' => 'ogDescription']);
snippet('seo/prompts/content'); ?>

View file

@ -0,0 +1,24 @@
<?php
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */
snippet('seo/prompts/introduction', [
'instructions' => $instructions ?? null,
'edit' => $edit ?? null
]); ?>
<task>
Create a useful GLOBAL meta description for this site <site-title><?= $site->title()->value() ?>.</site-title>
This description is meant as FALLBACK for when the page does not have a meta description itself.
This description should be unique and relevant to the site's content.
The entire meta description SHOULD be between 120 and 158 characters long.
You'll receive the content of the home page as well as any meta tags that are already set below.
</task>
<?php snippet('seo/prompts/site-meta', ['currentField' => 'metaDescription']);
snippet('seo/prompts/content'); ?>

View file

@ -0,0 +1,39 @@
<?php
use Kirby\Toolkit\Str;
/** @var \Kirby\Cms\Page $page
** @var \Kirby\Cms\Site $site
** @var string|null $instructions
** @var string|null $edit */
$meta = $page->metadata();
snippet('seo/prompts/introduction', [
'instructions' => $instructions ?? null,
'edit' => $edit ?? null
]); ?>
<task>
Create a useful meta title for this page called <page-title><?= $page->title()->value() ?></page-title>. <?php if ($page->isHomePage()) : ?>This page is the homepage of the website. AVOID an overly generic title such as 'Home'.<?php endif ?>
<?php if ($page->useTitleTemplate()->isEmpty() ? true : $page->useTitleTemplate()->toBool()):
$template = $meta->get('metaTemplate');
$templatePreview = $page->toString($template, ['title' => '{{ title }}']);
$templateBaseLength = Str::length($page->toString($template, ['title' => '']));
?>
The final page title will be rendered as:
<template><?= $templatePreview ?></template>
Where {{ title }} is your page title. The entire title SHOULD be between <?= max(0, 50 - $templateBaseLength) ?>-<?= max(max(0, 50 - $templateBaseLength), 60 - $templateBaseLength) ?> characters long.
DO NOT output the Title Template. ONLY output what should be placed inside {{ title }}. DO NOT repeat ANYTHING that exists in the template. You MUST NOT repeat the name of the site.
<?php else: ?>
Your response will be set as title without any changes. The entire title SHOULD be between 50-60 characters long.
<?php endif; ?>
If useful for the customers niche, include a keyword for the location. AVOID for global companies or niche subpages.
</task>
<?php snippet('seo/prompts/meta', ['currentField' => 'metaTitle']);
snippet('seo/prompts/content');

View file

@ -0,0 +1,52 @@
<?php
use Kirby\Toolkit\A;
use tobimori\Seo\Seo;
if ($content = Seo::option('robots.content')) {
if (is_callable($content)) {
$content = $content();
}
if (is_array($content)) {
$str = [];
foreach ($content as $ua => $data) {
$str[] = 'User-agent: ' . $ua;
foreach ($data as $type => $values) {
foreach ($values as $value) {
$str[] = $type . ': ' . $value;
}
}
}
$content = A::join($str, PHP_EOL);
}
echo $content;
} else {
// output default
echo "User-agent: *\n";
$index = Seo::option('robots.index');
if ($index) {
echo 'Allow: /';
echo "\nDisallow: /panel";
} else {
echo 'Disallow: /';
}
}
if (($sitemap = Seo::option('robots.sitemap')) || ($sitemapModule = Seo::option('sitemap.active'))) {
// Use default sitemap if none is set
if (!$sitemap && $sitemapModule) {
$sitemap = site()->canonicalFor('/sitemap.xml', true);
}
// Check again, so falsy values can't be used
if ($sitemap) {
echo "\n\nSitemap: {$sitemap}";
}
}

View file

@ -0,0 +1,12 @@
<?php
if (!class_exists('Spatie\SchemaOrg\Schema')) {
return;
}
$siteSchema ??= true;
$pageSchema ??= true;
foreach (array_merge($siteSchema ? $site->schemas() : [], $pageSchema ? $page->schemas() : []) as $schema) {
echo $schema;
}