feat: intégration plugin Kirby SEO
All checks were successful
Deploy / Deploy to Production (push) Successful in 22s
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:
parent
baab2fb3a1
commit
58c31ea391
133 changed files with 9201 additions and 253 deletions
27
site/plugins/kirby-seo/snippets/head.php
Normal file
27
site/plugins/kirby-seo/snippets/head.php
Normal 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;
|
||||
}
|
||||
114
site/plugins/kirby-seo/snippets/prompts/content.php
Normal file
114
site/plugins/kirby-seo/snippets/prompts/content.php
Normal 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>
|
||||
46
site/plugins/kirby-seo/snippets/prompts/introduction.php
Normal file
46
site/plugins/kirby-seo/snippets/prompts/introduction.php
Normal 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 ?>
|
||||
28
site/plugins/kirby-seo/snippets/prompts/meta.php
Normal file
28
site/plugins/kirby-seo/snippets/prompts/meta.php
Normal 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>
|
||||
25
site/plugins/kirby-seo/snippets/prompts/site-meta.php
Normal file
25
site/plugins/kirby-seo/snippets/prompts/site-meta.php
Normal 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>
|
||||
52
site/plugins/kirby-seo/snippets/prompts/tasks/alt-text.php
Normal file
52
site/plugins/kirby-seo/snippets/prompts/tasks/alt-text.php
Normal 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>
|
||||
|
|
@ -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');
|
||||
|
|
@ -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');
|
||||
|
|
@ -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'); ?>
|
||||
|
|
@ -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'); ?>
|
||||
39
site/plugins/kirby-seo/snippets/prompts/tasks/title.php
Normal file
39
site/plugins/kirby-seo/snippets/prompts/tasks/title.php
Normal 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');
|
||||
52
site/plugins/kirby-seo/snippets/robots.txt.php
Normal file
52
site/plugins/kirby-seo/snippets/robots.txt.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
12
site/plugins/kirby-seo/snippets/schemas.php
Normal file
12
site/plugins/kirby-seo/snippets/schemas.php
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue