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>
|
|
@ -130,3 +130,11 @@
|
|||
.k-code-editor-input[data-size="custom-size"] {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
input[type="checkbox"][data-variant="toggle"]::after {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
input[type="checkbox"][data-variant="toggle"]:checked::after {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"require": {
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"getkirby/cms": "^5.2",
|
||||
"sylvainjule/code-editor": "^1.1"
|
||||
"sylvainjule/code-editor": "^1.1",
|
||||
"tobimori/kirby-seo": "^2.0.0-beta.2"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
|
|
|||
74
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "07c4d6a85de1d10e6c6fc0e5cec09033",
|
||||
"content-hash": "f0e4fb17cfdab02969f2b43fc6538f97",
|
||||
"packages": [
|
||||
{
|
||||
"name": "christian-riesen/base32",
|
||||
|
|
@ -1241,12 +1241,82 @@
|
|||
}
|
||||
],
|
||||
"time": "2025-12-04T18:11:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tobimori/kirby-seo",
|
||||
"version": "2.0.0-beta.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tobimori/kirby-seo.git",
|
||||
"reference": "c16472022f53eba9c58ec73b10926129f889f86d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tobimori/kirby-seo/zipball/c16472022f53eba9c58ec73b10926129f889f86d",
|
||||
"reference": "c16472022f53eba9c58ec73b10926129f889f86d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"getkirby/composer-installer": "^1.2.1",
|
||||
"php": ">=8.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.48",
|
||||
"getkirby/cli": "^1.8.0",
|
||||
"getkirby/cms": "^5.0.0",
|
||||
"spatie/schema-org": "^3.23",
|
||||
"tobimori/kirby-queues": "^1.0.0-beta.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "Rasterize non-resizable images (SVG, etc.) for AI alt text generation",
|
||||
"getkirby/cli": "Enable background processing support",
|
||||
"spatie/schema-org": "Enable the Schema.org support",
|
||||
"tobimori/kirby-queues": "Enable background processing support"
|
||||
},
|
||||
"type": "kirby-plugin",
|
||||
"extra": {
|
||||
"kirby-cms-path": false
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"tobimori\\Seo\\": "classes"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"proprietary"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tobias Möritz",
|
||||
"email": "tobias@moeritz.io"
|
||||
}
|
||||
],
|
||||
"description": "The default choice for SEO on Kirby: Implement technical SEO & Meta best practices with ease and provide an easy-to-use editor experience",
|
||||
"homepage": "https://github.com/tobimori/kirby-seo#readme",
|
||||
"support": {
|
||||
"issues": "https://github.com/tobimori/kirby-seo/issues",
|
||||
"source": "https://github.com/tobimori/kirby-seo/tree/2.0.0-beta.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://plugins.andkindness.com/seo/preorder",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/tobimori",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-10T23:31:27+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {
|
||||
"tobimori/kirby-seo": 10
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
|
|
|||
|
|
@ -79,3 +79,5 @@ tabs:
|
|||
back: "#0e1e43"
|
||||
text: "{{ file.memberName }}"
|
||||
info: "{{ file.role }}"
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -111,3 +111,5 @@ tabs:
|
|||
query: site.find('blog').children.listed
|
||||
max: 3
|
||||
help: "Articles similaires à afficher en bas de page"
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -54,3 +54,5 @@ tabs:
|
|||
ratio: 16/9
|
||||
cover: true
|
||||
info: "{{ page.published.toDate('d/m/Y') }}"
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -1,29 +1,33 @@
|
|||
title: Default Page
|
||||
|
||||
columns:
|
||||
main:
|
||||
width: 2/3
|
||||
sections:
|
||||
fields:
|
||||
type: fields
|
||||
fields:
|
||||
text:
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
pages:
|
||||
type: pages
|
||||
template: default
|
||||
files:
|
||||
type: files
|
||||
tabs:
|
||||
content:
|
||||
label: Contenu
|
||||
columns:
|
||||
main:
|
||||
width: 2/3
|
||||
sections:
|
||||
fields:
|
||||
type: fields
|
||||
fields:
|
||||
text:
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
pages:
|
||||
type: pages
|
||||
template: default
|
||||
files:
|
||||
type: files
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -74,3 +74,5 @@ tabs:
|
|||
uploads:
|
||||
template: video
|
||||
width: 1/2
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -12,75 +12,80 @@ status:
|
|||
label: Public
|
||||
text: Le jeu est visible publiquement
|
||||
|
||||
columns:
|
||||
- width: 2/10
|
||||
fields:
|
||||
thumbnail:
|
||||
label: Vignette
|
||||
type: files
|
||||
layout: cards
|
||||
size: small
|
||||
max: 1
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
uploads:
|
||||
template: image
|
||||
help: Pour le carousel de navigation parmi les jeux
|
||||
- width: 2/10
|
||||
fields:
|
||||
lettering:
|
||||
label: Lettrage
|
||||
type: files
|
||||
multiple: false
|
||||
translate: false
|
||||
layout: cards
|
||||
size: tiny
|
||||
uploads:
|
||||
template: image
|
||||
help: Affiché au-dessus de la description
|
||||
width: 1/3
|
||||
- width: 3/10
|
||||
fields:
|
||||
description:
|
||||
label: Description
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
maxlength: 200
|
||||
playLink:
|
||||
label: Lien(s) pour jouer
|
||||
help: Laissez vide pour afficher "à venir / coming soon"
|
||||
translate: false
|
||||
type: url
|
||||
|
||||
- width: 3/10
|
||||
sections:
|
||||
previewCol:
|
||||
type: fields
|
||||
tabs:
|
||||
content:
|
||||
label: Contenu
|
||||
columns:
|
||||
- width: 2/10
|
||||
fields:
|
||||
backgroundColor:
|
||||
label: Couleur d'arrière plan
|
||||
type: code-editor
|
||||
language: css
|
||||
size: custom-size
|
||||
default: radial-gradient(circle at 20% 80%, rgb(240, 154, 110) 0%, rgb(233, 101, 122) 100%)
|
||||
translate: false
|
||||
help: À remplir avec la valeur de la propriété CSS `background-color` souhaitée
|
||||
preview:
|
||||
label: Aperçu
|
||||
thumbnail:
|
||||
label: Vignette
|
||||
type: files
|
||||
layout: cards
|
||||
multiple: false
|
||||
size: small
|
||||
max: 1
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
uploads:
|
||||
template: image
|
||||
help: Image affichée à droite de la description (sur ordinateur uniquement)
|
||||
help: Pour le carousel de navigation parmi les jeux
|
||||
- width: 2/10
|
||||
fields:
|
||||
lettering:
|
||||
label: Lettrage
|
||||
type: files
|
||||
multiple: false
|
||||
translate: false
|
||||
layout: cards
|
||||
size: tiny
|
||||
uploads:
|
||||
template: image
|
||||
help: Affiché au-dessus de la description
|
||||
width: 1/3
|
||||
- width: 3/10
|
||||
fields:
|
||||
description:
|
||||
label: Description
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
maxlength: 200
|
||||
playLink:
|
||||
label: Lien(s) pour jouer
|
||||
help: Laissez vide pour afficher "à venir / coming soon"
|
||||
translate: false
|
||||
type: url
|
||||
|
||||
- width: 3/10
|
||||
sections:
|
||||
previewCol:
|
||||
type: fields
|
||||
fields:
|
||||
backgroundColor:
|
||||
label: Couleur d'arrière plan
|
||||
type: code-editor
|
||||
language: css
|
||||
size: custom-size
|
||||
default: radial-gradient(circle at 20% 80%, rgb(240, 154, 110) 0%, rgb(233, 101, 122) 100%)
|
||||
translate: false
|
||||
help: À remplir avec la valeur de la propriété CSS `background-color` souhaitée
|
||||
preview:
|
||||
label: Aperçu
|
||||
type: files
|
||||
layout: cards
|
||||
multiple: false
|
||||
translate: false
|
||||
uploads:
|
||||
template: image
|
||||
help: Image affichée à droite de la description (sur ordinateur uniquement)
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -85,3 +85,5 @@ tabs:
|
|||
cover: true
|
||||
help: Image affichée au besoin pendant le chargement de la vidéo
|
||||
width: 1/2
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -24,3 +24,5 @@ tabs:
|
|||
template: game
|
||||
info: "{{ page.statusLabel }}"
|
||||
create: game
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -44,3 +44,5 @@ tabs:
|
|||
template: project
|
||||
info: "{{ page.catchPhrase }}"
|
||||
create: project
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -12,150 +12,155 @@ icon: image
|
|||
# label: Publié
|
||||
# text: Le projet est visible publiquement
|
||||
|
||||
columns:
|
||||
# Colonne principale
|
||||
main:
|
||||
width: 2/3
|
||||
sections:
|
||||
# Informations principales
|
||||
info:
|
||||
type: fields
|
||||
fields:
|
||||
catchPhrase:
|
||||
label: Phrase d'accroche
|
||||
type: writer
|
||||
nodes: false
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
help: "Ex: Transformez votre lecture en aventure."
|
||||
description:
|
||||
label: Description
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
maxlength: 500
|
||||
help: Description complète du projet
|
||||
|
||||
images:
|
||||
type: fields
|
||||
fields:
|
||||
imagesGallery:
|
||||
width: 2/3
|
||||
label: Galerie d'images
|
||||
type: files
|
||||
layout: cards
|
||||
size: small
|
||||
min: 6
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
back: #ffffff
|
||||
uploads:
|
||||
template: image
|
||||
help: "Une animation sera générée à partir des images de ce champ. Minimum optimal d'images : 6"
|
||||
mockup:
|
||||
width: 1/3
|
||||
label: Image de mise en situation
|
||||
type: files
|
||||
layout: cards
|
||||
max: 1
|
||||
size: small
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
back: #ffffff
|
||||
uploads:
|
||||
template: image
|
||||
help: Écran de jeu mis en situation sur un appareil
|
||||
|
||||
# Sidebar
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
meta:
|
||||
type: fields
|
||||
fields:
|
||||
thumbnail:
|
||||
label: Vignette
|
||||
type: files
|
||||
required: true
|
||||
layout: cards
|
||||
size: small
|
||||
max: 1
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
uploads:
|
||||
template: image
|
||||
help: Petite image carrée représentant le jeu
|
||||
galleryAnimationMode:
|
||||
label: Mode d'animation de la galerie
|
||||
type: toggles
|
||||
default: vertical
|
||||
options:
|
||||
- value: vertical
|
||||
text: Vertical
|
||||
icon: arrow-up-down
|
||||
- value: horizontal
|
||||
text: Horizontal
|
||||
icon: arrow-left-right
|
||||
help: "Direction du défilement des images dans la galerie animée"
|
||||
galleryBackgroundColor:
|
||||
label: Couleur d'arrière-plan de la galerie animée
|
||||
type: color
|
||||
alpha: false
|
||||
galleryBackgroundImage:
|
||||
label: Image d'arrière-plan
|
||||
type: files
|
||||
multiple: false
|
||||
keywords:
|
||||
label: Mots clés
|
||||
type: structure
|
||||
tabs:
|
||||
content:
|
||||
label: Contenu
|
||||
columns:
|
||||
# Colonne principale
|
||||
main:
|
||||
width: 2/3
|
||||
sections:
|
||||
# Informations principales
|
||||
info:
|
||||
type: fields
|
||||
fields:
|
||||
label:
|
||||
label: Label
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
placeholder: "Ex: Impact, Catégorie..."
|
||||
text:
|
||||
label: Texte
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
catchPhrase:
|
||||
label: Phrase d'accroche
|
||||
type: writer
|
||||
nodes: false
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
help: "Ex: Transformez votre lecture en aventure."
|
||||
description:
|
||||
label: Description
|
||||
type: writer
|
||||
marks:
|
||||
- bold
|
||||
- italic
|
||||
- green
|
||||
- pixel
|
||||
- underline
|
||||
- strike
|
||||
- clear
|
||||
- link
|
||||
maxlength: 500
|
||||
help: Description complète du projet
|
||||
|
||||
# Liens externes
|
||||
links:
|
||||
type: fields
|
||||
fields:
|
||||
externalLinks:
|
||||
label: Liens externes
|
||||
type: structure
|
||||
images:
|
||||
type: fields
|
||||
fields:
|
||||
label:
|
||||
label: Nom du bouton
|
||||
imagesGallery:
|
||||
width: 2/3
|
||||
label: Galerie d'images
|
||||
type: files
|
||||
layout: cards
|
||||
size: small
|
||||
min: 6
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
back: #ffffff
|
||||
uploads:
|
||||
template: image
|
||||
help: "Une animation sera générée à partir des images de ce champ. Minimum optimal d'images : 6"
|
||||
mockup:
|
||||
width: 1/3
|
||||
label: Image de mise en situation
|
||||
type: files
|
||||
layout: cards
|
||||
max: 1
|
||||
size: small
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
back: #ffffff
|
||||
uploads:
|
||||
template: image
|
||||
help: Écran de jeu mis en situation sur un appareil
|
||||
|
||||
# Sidebar
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
meta:
|
||||
type: fields
|
||||
fields:
|
||||
thumbnail:
|
||||
label: Vignette
|
||||
type: files
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
placeholder: "Ex: App Store, Site web..."
|
||||
url:
|
||||
label: URL
|
||||
required: true
|
||||
type: url
|
||||
width: 1/2
|
||||
layout: cards
|
||||
size: small
|
||||
max: 1
|
||||
accept: image/*
|
||||
translate: false
|
||||
image:
|
||||
ratio: 1/1
|
||||
uploads:
|
||||
template: image
|
||||
help: Petite image carrée représentant le jeu
|
||||
galleryAnimationMode:
|
||||
label: Mode d'animation de la galerie
|
||||
type: toggles
|
||||
default: vertical
|
||||
options:
|
||||
- value: vertical
|
||||
text: Vertical
|
||||
icon: arrow-up-down
|
||||
- value: horizontal
|
||||
text: Horizontal
|
||||
icon: arrow-left-right
|
||||
help: "Direction du défilement des images dans la galerie animée"
|
||||
galleryBackgroundColor:
|
||||
label: Couleur d'arrière-plan de la galerie animée
|
||||
type: color
|
||||
alpha: false
|
||||
galleryBackgroundImage:
|
||||
label: Image d'arrière-plan
|
||||
type: files
|
||||
multiple: false
|
||||
keywords:
|
||||
label: Mots clés
|
||||
type: structure
|
||||
fields:
|
||||
label:
|
||||
label: Label
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
placeholder: "Ex: Impact, Catégorie..."
|
||||
text:
|
||||
label: Texte
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
|
||||
# Liens externes
|
||||
links:
|
||||
type: fields
|
||||
fields:
|
||||
externalLinks:
|
||||
label: Liens externes
|
||||
type: structure
|
||||
fields:
|
||||
label:
|
||||
label: Nom du bouton
|
||||
required: true
|
||||
type: text
|
||||
width: 1/2
|
||||
placeholder: "Ex: App Store, Site web..."
|
||||
url:
|
||||
label: URL
|
||||
required: true
|
||||
type: url
|
||||
width: 1/2
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -59,3 +59,5 @@ tabs:
|
|||
accept: application/pdf
|
||||
translate: false
|
||||
help: Fichier téléchargé après soumission du formulaire
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -88,3 +88,5 @@ tabs:
|
|||
downloadedAt:
|
||||
type: text
|
||||
label: Date
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
title: World Game
|
||||
|
||||
tabs:
|
||||
seo: seo/site
|
||||
mainTab:
|
||||
label: Principal
|
||||
columns:
|
||||
|
|
|
|||
|
|
@ -15,4 +15,16 @@ return [
|
|||
'routes' => [
|
||||
require(__DIR__ . '/routes/download-white-paper.php')
|
||||
],
|
||||
|
||||
'tobimori.seo' => [
|
||||
// TODO: définir l'URL de production pour éviter le duplicate content
|
||||
// 'canonicalBase' => 'https://www.worldgame.fr',
|
||||
'sitemap' => [
|
||||
'excludeTemplates' => ['error'],
|
||||
'changefreq' => 'weekly',
|
||||
],
|
||||
'robots' => [
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
132
site/plugins/kirby-seo/LICENSE.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Plugin License Agreement
|
||||
|
||||
Source: https://plugins.andkindness.com/license-agreement
|
||||
|
||||
While most of our plugins source code are publicly available, they are, unless specified otherwise, not free software. To use any plugin in production, you need to purchase a license.
|
||||
|
||||
## Summary
|
||||
|
||||
> This is a legally non-binding summary. Please review the full license text carefully before using the plugins.
|
||||
|
||||
This is a legal agreement between you (the customer) and Love & Kindness GmbH for using their Kirby CMS plugins. By downloading or using the plugins, or by purchasing a license, you agree to these terms:
|
||||
|
||||
### What You Can Do
|
||||
|
||||
- **Install and use the plugin on one website or multi-language website per license purchase**
|
||||
If you need a separate Kirby CMS license for your site, you'll most likely also need a separate plugin license
|
||||
- **Make copies of the plugin for backup or development purposes**
|
||||
- **Modify the source code for your own use**
|
||||
|
||||
### What You Cannot Do
|
||||
|
||||
- **Use the plugin on additional websites without buying additional licenses**
|
||||
- **Redistribute or resell the plugin or your modified versions**
|
||||
|
||||
### Support & Updates
|
||||
|
||||
- **Free updates for minor/patch releases, paid upgrades for major releases possible**
|
||||
- **Email support provided for active license holders**
|
||||
|
||||
---
|
||||
|
||||
This license is a legal agreement between **You** and **Love & Kindness GmbH, Beimoorstr. 20, 22081 Hamburg, Germany** (therein "**Our**"/"**We**"/"**Us**") for the use of any Kirby CMS plugins and resources (the "**Plugin**") created by Us and sold via Paddle.com. By downloading any Plugin files or resources or purchasing a license to the Plugin, you agree to be bound by the terms and conditions of this license.
|
||||
|
||||
## Permitted Use
|
||||
|
||||
This agreement grants a license for each purchase to install and use a single instance of the Plugin on a **specific website** limited by **its domain & subdomain**. If You use the cross-domain multi-language feature with the same `content` folder, these domains count as the same Website.
|
||||
|
||||
Additional Plugin licenses must be purchased in order to install and use the Plugin on **additional websites**.
|
||||
|
||||
The license is **non-exclusive** and **generally non-transferable**.
|
||||
|
||||
A license is valid for all minor & patch updates of the Plugin (e.g. 1.0.x to 1.1.x). We reserve the right to charge an **upgrade fee for major updates** (e.g. 1.x.x to 2.x.x). Whether a release is a patch, minor, or major release is at Our sole discretion.
|
||||
|
||||
## Development Usage
|
||||
|
||||
You are permitted to install and use the Plugin on a personal computer (such as a desktop PC, notebook, or tablet) or a server, free of charge, for as long as necessary during the development stage.
|
||||
|
||||
Any website that is used **purely for the purposes of development and client preview** is considered development usage. It must only be accessible by a restricted number of users. A website with **the intention to handle production data** is never considered development usage, no matter if the related website is publicly accessible or not.
|
||||
|
||||
## Refund Policy
|
||||
|
||||
We offer refunds on the Plugin within **14 days of purchase**. Contact support@andkindness.com for assistance.
|
||||
|
||||
## Technical Support
|
||||
|
||||
Technical support is available via email for active license owners.
|
||||
|
||||
No support is provided for free plugins or plugins that are available both for free and paid usage. We do not provide phone support. No representations or guarantees are made regarding the response time in which support questions are answered, but we will do our very best to respond quickly.
|
||||
|
||||
## All Rights Reserved
|
||||
|
||||
Love & Kindness GmbH **owns all rights**, title and interest to the Plugin (including all intellectual property rights) and **reserves all rights to the Plugin** that are not expressly granted in this Agreement.
|
||||
|
||||
## Restrictions
|
||||
|
||||
### Making Copies
|
||||
|
||||
You may make **copies of the Plugin** in any machine readable form solely for purposes of **deploying a website to a server, developing a website on a personal computer or server or as a backup**, provided that You reproduce the Plugin in its original form and with all proprietary notices on the copy.
|
||||
|
||||
You may not reproduce the Plugin or its source code, in whole or in part, for **any other purpose**.
|
||||
|
||||
### Modification of the Source Code
|
||||
|
||||
You may **alter, modify or extend the source code** for Your own use. You may also **commission a third party** to perform those modifications for You.
|
||||
|
||||
However You may not **alter or circumvent the licensing features**, including (but not limited to) the license validation or **resell, redistribute or transfer** the modified or derivative version.
|
||||
|
||||
### Ownership and Intellectual Property
|
||||
|
||||
The Plugin is copyrighted by Us. All rights not expressly granted to You are retained by Us, including intellectual property rights.
|
||||
|
||||
### Disclaimer of Warranty
|
||||
|
||||
THE PLUGIN IS OFFERED ON AN **"AS-IS" BASIS** AND **NO WARRANTY**, EITHER EXPRESSED OR IMPLIED, IS GIVEN. WE EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. YOU ASSUME ALL RISK ASSOCIATED WITH THE QUALITY, PERFORMANCE, INSTALLATION AND USE OF THE PLUGIN INCLUDING, BUT NOT LIMITED TO, THE RISKS OF PROGRAM ERRORS, DAMAGE TO EQUIPMENT, LOSS OF DATA OR SOFTWARE PROGRAMS, OR UNAVAILABILITY OR INTERRUPTION OF OPERATIONS. **YOU ARE SOLELY RESPONSIBLE** FOR DETERMINING THE APPROPRIATENESS OF USE OF THE PLUGIN AND ASSUME ALL RISKS ASSOCIATED WITH ITS USE. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE THE PLUGIN WHILE SOMEONE ELSE IS THE LICENSEE).
|
||||
|
||||
### Limitations of Liability
|
||||
|
||||
YOU EXPRESSLY UNDERSTAND AND AGREE THAT **WE SHALL NOT BE LIABLE** FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, GOODWILL, USE, DATA OR OTHER INTANGIBLE LOSSES (EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES). SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF THE LIMITATION OR EXCLUSION OF LIABILITY FOR INCIDENTAL OR CONSEQUENTIAL DAMAGES. ACCORDINGLY, **SOME OF THE ABOVE LIMITATIONS MAY NOT APPLY TO YOU**. **IN NO EVENT WILL OUR TOTAL CUMULATIVE DAMAGES EXCEED** THE FEES YOU PAID TO US UNDER THIS AGREEMENT IN THE MOST RECENT TWELVE-MONTH PERIOD. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE THE PLUGIN WHILE SOMEONE ELSE IS THE LICENSEE).
|
||||
|
||||
## Termination
|
||||
|
||||
The License may be terminated by either party if terms are breached and not remedied within a specified period.
|
||||
|
||||
## Governing Law
|
||||
|
||||
Any legal disputes that arise from or relate to this Agreement shall be exclusively resolved in the courts located in Hamburg, Germany. Nonetheless, we reserve the right to initiate legal proceedings against you in the jurisdiction where your principal place of business is situated.
|
||||
|
||||
Should you be domiciled in Germany, the stipulations of the first paragraph will be relevant only if you are a businessperson, a public law entity, or a special fund under public law.
|
||||
|
||||
If your domicile is not in Germany but within another member state of the European Union, the provisions of the first paragraph will apply to you only if you do not qualify as a consumer as defined under Article 17 of Regulation (EU) No. 1215/2012. In such circumstances, you may bring a legal action against us either in the jurisdiction of our place of business or where you habitually reside. Conversely, we are entitled to sue you exclusively in the courts of the member state where you have your domicile.
|
||||
|
||||
In the event that your residence is outside Germany and not within any European Union member state, the provisions of the first paragraph are fully applicable without any modification.
|
||||
|
||||
## Severability Clause
|
||||
|
||||
Should any provision of this Agreement be or become invalid, void or unenforceable, in whole or in part, at present or in the future, this shall not affect the validity of the remaining provisions of this Agreement. The same shall apply if a gap requiring supplementation arises after conclusion of this Agreement. The parties shall replace the invalid, void or unenforceable provision or gap requiring filling by a valid provision which in its legal or economic content takes account of the invalid, void provision and the overall content of the agreement. § 139 BGB (partial invalidity) is expressly waived.
|
||||
|
||||
---
|
||||
|
||||
Kirby SEO 1.x releases were previously licensed under the MIT License.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2024 Tobias Möritz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
52
site/plugins/kirby-seo/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||

|
||||
|
||||
<h1 align="center">Kirby SEO</h1>
|
||||
<p align="center">
|
||||
The default choice for SEO on Kirby: Implement technical SEO & Meta best practices with ease and provide an easy-to-use editor experience
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🔎 All-in-one SEO and meta solution
|
||||
- 🪜 The Meta Cascade: Intelligently merge metadata from multiple sources
|
||||
- 🎛 Completely configurable: Disable features you don't need
|
||||
- 💻 Simple Panel UI with previews for Google, Twitter, Facebook & Co.
|
||||
- 📮 [Schema.org (JSON-LD)](https://schema.org/) support with fluent classes
|
||||
- 🤖 Automatic Robots rule generation, based on page status
|
||||
- 📝 Sitemap generation with multi-lang support
|
||||
|
||||
### New in Version 2
|
||||
|
||||
- 🚀 Kirby 5 support
|
||||
- 💻 Even better and easier Panel UI
|
||||
- ✨ AI assist for writing meta tags
|
||||
- 🔘 IndexNow support
|
||||
|
||||
## Get started
|
||||
|
||||
[Read the documentation](https://plugins.andkindness.com/seo/docs/get-started/feature-overview) to get started with Kirby SEO.
|
||||
|
||||
If you're looking to use Kirby SEO with Kirby 5 or newer, please install the Beta version of the plugin:
|
||||
|
||||
`composer require tobimori/kirby-seo:^2.0.0-beta.2`
|
||||
|
||||
### What does Beta mean for Kirby SEO 2?
|
||||
|
||||
The core features of Kirby SEO, such as the meta cascade, the panel setup, sitemap and robots are stable and can be used in production. New features of v2 might be unstable or can occur breaking changes until the final release.
|
||||
|
||||
## Contributing
|
||||
|
||||
Kirby SEO is open to contributors: If you open a pull request that gets merged, such as fixing a bug or translating the plugin into a new language, you're eligible for a free SEO license of your choice. Please note that I might reject minor repeat contributions or simple fixes of typos for this. Please send an email to support after your contribution has been merged.
|
||||
|
||||
## License
|
||||
|
||||
Kirby SEO 2.0 is not free software. In order to run it on a public server, you'll have to purchase a valid Kirby license & a valid SEO license.
|
||||
**The plugin is currently free to use while in pre-release state.** You can [pre-order a license](https://plugins.andkindness.com/seo/preorder) with a 20% discount for a limited time.
|
||||
|
||||
Copyright 2023-2025 © Tobias Möritz - Love & Kindness GmbH
|
||||
|
||||
---
|
||||
|
||||
[Kirby SEO 1.0 is licensed under the MIT license.](./LICENSE.md)
|
||||
34
site/plugins/kirby-seo/blueprints/fields/meta-group.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type: group
|
||||
fields:
|
||||
_metaHeadline:
|
||||
label: seo.page.meta.headline
|
||||
type: headline
|
||||
numbered: false
|
||||
metaTitle:
|
||||
label: seo.fields.titleOverwrite.label
|
||||
type: seo-writer
|
||||
ai: title
|
||||
placeholder: "{{ page.title }}"
|
||||
metaTemplate:
|
||||
extends: seo/fields/title-template
|
||||
label: seo.fields.metaTitleTemplate.label
|
||||
help: seo.fields.metaTitleTemplate.help
|
||||
width: 2/3
|
||||
placeholder: "{{ page.metadata.metaTemplate }}"
|
||||
useTitleTemplate:
|
||||
label: seo.fields.useTitleTemplate.label
|
||||
type: toggle
|
||||
help: seo.fields.useTitleTemplate.help
|
||||
width: 1/3
|
||||
default: true
|
||||
text:
|
||||
- "{{ t('seo.fields.useTitleTemplate.no') }}"
|
||||
- "{{ t('seo.fields.useTitleTemplate.yes') }}"
|
||||
metaDescription:
|
||||
label: seo.fields.metaDescription.label
|
||||
type: seo-writer
|
||||
ai: description
|
||||
help: seo.fields.metaDescription.help
|
||||
placeholder: "{{ page.metadata.metaDescription }}"
|
||||
_seoLine1:
|
||||
type: line
|
||||
42
site/plugins/kirby-seo/blueprints/fields/og-group.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
type: group
|
||||
fields:
|
||||
_ogHeadline:
|
||||
label: seo.page.og.headline
|
||||
type: headline
|
||||
numbered: false
|
||||
help: seo.site.og.headline.help
|
||||
ogTemplate:
|
||||
extends: seo/fields/title-template
|
||||
label: seo.fields.ogTitleTemplate.label
|
||||
width: 2/3
|
||||
help: seo.fields.metaTitleTemplate.help
|
||||
placeholder: "{{ page.metadata.ogTemplate }}"
|
||||
useOgTemplate:
|
||||
label: seo.fields.useTitleTemplate.label
|
||||
type: toggle
|
||||
help: seo.fields.useTitleTemplate.help
|
||||
width: 1/3
|
||||
default: true
|
||||
text:
|
||||
- "{{ t('seo.fields.useTitleTemplate.no') }}"
|
||||
- "{{ t('seo.fields.useTitleTemplate.yes') }}"
|
||||
ogDescription:
|
||||
label: seo.fields.ogDescription.label
|
||||
type: seo-writer
|
||||
ai: og-description
|
||||
placeholder: "{{ page.metadata.ogDescription }}"
|
||||
ogImage:
|
||||
label: seo.fields.ogImage.label
|
||||
extends: seo/fields/og-image
|
||||
empty: seo.fields.ogImage.empty
|
||||
cropOgImage:
|
||||
label: seo.fields.cropOgImage.label
|
||||
type: select
|
||||
width: 1/1
|
||||
placeholder: "{{ t('seo.common.default') }} {{ site.cropOgImage.toBool ? t('seo.common.yes') : t('seo.common.no') }}"
|
||||
options:
|
||||
"true": "{{ t('seo.common.yes') }}"
|
||||
"false": "{{ t('seo.common.no') }}"
|
||||
help: seo.fields.cropOgImage.help
|
||||
_seoLine2:
|
||||
type: line
|
||||
31
site/plugins/kirby-seo/blueprints/fields/og-image.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
return function (App $kirby) {
|
||||
$blueprint = [
|
||||
'type' => 'files',
|
||||
'multiple' => false,
|
||||
'uploads' => [],
|
||||
'query' => 'model.images'
|
||||
];
|
||||
|
||||
if ($parent = option('tobimori.seo.files.parent')) {
|
||||
$blueprint['uploads'] = [
|
||||
'parent' => $parent
|
||||
];
|
||||
$blueprint['query'] = "{$parent}.images";
|
||||
}
|
||||
|
||||
if ($template = option('tobimori.seo.files.template')) {
|
||||
$blueprint['uploads'] = [
|
||||
...$blueprint['uploads'],
|
||||
'template' => $template
|
||||
];
|
||||
|
||||
$blueprint['query'] = "{$blueprint['query']}.filterBy('template', '{$template}')";
|
||||
}
|
||||
|
||||
return $blueprint;
|
||||
};
|
||||
56
site/plugins/kirby-seo/blueprints/fields/robots.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Meta;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return function (App $kirby) {
|
||||
if (!Seo::option('robots.active') || !Seo::option('robots.pageSettings')) {
|
||||
return [
|
||||
'type' => 'hidden'
|
||||
];
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'_robotsHeadline' => [
|
||||
'label' => 'seo.fields.robots.label',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
]
|
||||
];
|
||||
|
||||
$page = Meta::currentPage();
|
||||
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
|
||||
$upper = Str::ucfirst($robots);
|
||||
|
||||
$fields["robots{$upper}"] = [
|
||||
'label' => "seo.fields.robots.{$robots}.label",
|
||||
'type' => 'toggles',
|
||||
'help' => "seo.fields.robots.{$robots}.help",
|
||||
'width' => '1/2',
|
||||
'default' => 'default',
|
||||
'reset' => false,
|
||||
'options' => [
|
||||
'default' => $page ?
|
||||
A::join([
|
||||
t('seo.common.default'),
|
||||
$page->metadata()->get("robots{$upper}", ['fields'])->toBool() ? t('seo.common.yes') : t('seo.common.no')
|
||||
], ' ')
|
||||
: t('seo.common.default'),
|
||||
'true' => t('seo.common.yes'),
|
||||
'false' => t('seo.common.no'),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$fields['_seoLine3'] = [
|
||||
'type' => 'line'
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'group',
|
||||
'fields' => $fields,
|
||||
];
|
||||
};
|
||||
49
site/plugins/kirby-seo/blueprints/fields/site-robots.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
|
||||
return function (App $kirby) {
|
||||
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
|
||||
return [
|
||||
'type' => 'hidden'
|
||||
];
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'_robotsHeadline' => [
|
||||
'label' => 'seo.fields.robots.label',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
|
||||
$index = $kirby->option('tobimori.seo.robots.index');
|
||||
if (is_callable($index)) {
|
||||
$index = $index();
|
||||
}
|
||||
|
||||
$fields["robots{$robots}"] = [
|
||||
'label' => "seo.fields.robots.{$robots}.label",
|
||||
'type' => 'toggles',
|
||||
'help' => "seo.fields.robots.{$robots}.help",
|
||||
'width' => '1/2',
|
||||
'default' => 'default',
|
||||
'reset' => false,
|
||||
'options' => [
|
||||
'default' => t('seo.common.default') . ' ' . ($index ? t('seo.common.yes') : t('seo.common.no')),
|
||||
'true' => t('seo.common.yes'),
|
||||
'false' => t('seo.common.no'),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$fields['_seoLine3'] = [
|
||||
'type' => 'line'
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'group',
|
||||
'fields' => $fields,
|
||||
];
|
||||
};
|
||||
30
site/plugins/kirby-seo/blueprints/fields/social-media.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Social Media Accounts field
|
||||
* Allows social media account list to be filled by config options
|
||||
*/
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return function () {
|
||||
$fields = [];
|
||||
|
||||
foreach (Seo::option('socialMedia') as $key => $value) {
|
||||
if ($value) {
|
||||
$fields[$key] = [
|
||||
'label' => ucfirst($key),
|
||||
'type' => 'url',
|
||||
'icon' => strtolower($key),
|
||||
'placeholder' => $value
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'seo.fields.socialMediaAccounts.label',
|
||||
'type' => 'object',
|
||||
'help' => 'seo.fields.socialMediaAccounts.help',
|
||||
'fields' => $fields
|
||||
];
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
type: seo-writer
|
||||
nodes:
|
||||
- seoTemplateTitle
|
||||
- seoTemplateSiteTitle
|
||||
toolbar:
|
||||
inline: false
|
||||
60
site/plugins/kirby-seo/blueprints/page.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'label' => 'seo.tabs.seo',
|
||||
'icon' => 'search',
|
||||
'columns' => [
|
||||
'main' => [
|
||||
'width' => '7/12',
|
||||
'fields' => [
|
||||
'metaGroup' => 'seo/fields/meta-group',
|
||||
'ogGroup' => 'seo/fields/og-group',
|
||||
'robots' => 'seo/fields/robots',
|
||||
'metaInherit' => [
|
||||
'label' => 'seo.fields.inheritSettings.label',
|
||||
'type' => 'multiselect',
|
||||
'help' => 'seo.fields.inheritSettings.help',
|
||||
'options' => [
|
||||
'metaTemplate' => [
|
||||
'*' => 'seo.fields.metaTitleTemplate.label'
|
||||
],
|
||||
'metaDescription' => [
|
||||
'*' => 'seo.fields.metaDescription.label'
|
||||
],
|
||||
'ogTemplate' => [
|
||||
'*' => 'seo.fields.ogTitleTemplate.label'
|
||||
],
|
||||
'ogDescription' => [
|
||||
'*' => 'seo.fields.ogDescription.label'
|
||||
],
|
||||
'ogImage' => [
|
||||
'*' => 'seo.fields.ogImage.label'
|
||||
],
|
||||
'cropOgImage' => [
|
||||
'*' => 'seo.fields.cropOgImage.label'
|
||||
],
|
||||
'robots' => [
|
||||
'*' => 'seo.fields.robots.label'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'sidebar' => [
|
||||
'width' => '5/12',
|
||||
'sticky' => true,
|
||||
'sections' => [
|
||||
'seoPreview' => [
|
||||
'type' => 'seo-preview'
|
||||
],
|
||||
...(Seo::option('searchConsole.enabled') ? [
|
||||
'seoSearchConsole' => [
|
||||
'type' => 'seo-search-console'
|
||||
]
|
||||
] : [])
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
15
site/plugins/kirby-seo/blueprints/seo.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
return function (App $kirby) {
|
||||
$path = $kirby->request()->url()->toString();
|
||||
$isSite = Str::contains($path, '/site') && !Str::contains($path, '/pages/');
|
||||
|
||||
if ($isSite) {
|
||||
return require __DIR__ . '/site.php';
|
||||
}
|
||||
|
||||
return require __DIR__ . '/page.php';
|
||||
};
|
||||
95
site/plugins/kirby-seo/blueprints/site.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'label' => 'seo.tabs.seo',
|
||||
'icon' => 'search',
|
||||
'columns' => [
|
||||
'main' => [
|
||||
'width' => '7/12',
|
||||
'fields' => [
|
||||
'_metaHeadline' => [
|
||||
'label' => 'seo.site.meta.headline',
|
||||
'type' => 'headline',
|
||||
'help' => 'seo.site.meta.headline.help'
|
||||
],
|
||||
'metaTemplate' => [
|
||||
'extends' => 'seo/fields/title-template',
|
||||
'label' => 'seo.fields.metaTitleTemplate.label',
|
||||
'help' => 'seo.fields.metaTitleTemplate.help'
|
||||
],
|
||||
'metaDescription' => [
|
||||
'label' => 'seo.fields.metaDescription.label',
|
||||
'type' => 'seo-writer',
|
||||
'ai' => 'site-description',
|
||||
'help' => 'seo.fields.metaDescription.help'
|
||||
],
|
||||
'_seoLine1' => [
|
||||
'type' => 'line'
|
||||
],
|
||||
'_ogHeadline' => [
|
||||
'label' => 'seo.site.og.headline',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
'help' => 'seo.site.og.headline.help'
|
||||
],
|
||||
'ogTemplate' => [
|
||||
'extends' => 'seo/fields/title-template',
|
||||
'label' => 'seo.fields.ogTitleTemplate.label',
|
||||
'default' => '{{ title }}',
|
||||
'help' => 'seo.fields.metaTitleTemplate.help',
|
||||
'placeholder' => '{{ site.metaTemplate }}'
|
||||
],
|
||||
'ogDescription' => [
|
||||
'label' => 'seo.fields.ogDescription.label',
|
||||
'type' => 'seo-writer',
|
||||
'ai' => 'og-site-description',
|
||||
'placeholder' => '{{ site.metaDescription }}'
|
||||
],
|
||||
'ogSiteName' => [
|
||||
'label' => 'seo.fields.ogSiteName.label',
|
||||
'type' => 'text',
|
||||
'default' => '{{ site.title }}',
|
||||
'placeholder' => '{{ site.title }}',
|
||||
'width' => '1/2'
|
||||
],
|
||||
'ogImage' => [
|
||||
'label' => 'seo.fields.ogImage.label',
|
||||
'extends' => 'seo/fields/og-image',
|
||||
'empty' => 'seo.fields.ogImage.empty',
|
||||
'width' => '1/2'
|
||||
],
|
||||
'cropOgImage' => [
|
||||
'label' => 'seo.fields.cropOgImage.label',
|
||||
'type' => 'toggle',
|
||||
'default' => true,
|
||||
'text' => [
|
||||
"{{ t('seo.common.no') }}",
|
||||
"{{ t('seo.common.yes') }}"
|
||||
],
|
||||
'help' => 'seo.fields.cropOgImage.help'
|
||||
],
|
||||
'_seoLine2' => [
|
||||
'type' => 'line'
|
||||
],
|
||||
'robots' => 'seo/fields/site-robots',
|
||||
'socialMediaAccounts' => 'seo/fields/social-media'
|
||||
]
|
||||
],
|
||||
'sidebar' => [
|
||||
'width' => '5/12',
|
||||
'sticky' => true,
|
||||
'sections' => [
|
||||
'seoPreview' => [
|
||||
'type' => 'seo-preview'
|
||||
],
|
||||
...(Seo::option('searchConsole.enabled') ? [
|
||||
'seoSearchConsole' => [
|
||||
'type' => 'seo-search-console'
|
||||
]
|
||||
] : [])
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
65
site/plugins/kirby-seo/classes/Ai.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
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.");
|
||||
}
|
||||
|
||||
$content = [Content::user()->text($prompt)];
|
||||
|
||||
return self::provider()->stream($content);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
86
site/plugins/kirby-seo/classes/Ai/Content.php
Normal 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;
|
||||
}
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
140
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
149
site/plugins/kirby-seo/classes/Ai/Drivers/Gemini.php
Normal 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];
|
||||
}
|
||||
}
|
||||
118
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal 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;
|
||||
}
|
||||
}
|
||||
194
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal 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);
|
||||
}
|
||||
}
|
||||
126
site/plugins/kirby-seo/classes/AltText.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Data\Yaml;
|
||||
|
||||
/**
|
||||
* Value object for structured alt text data.
|
||||
* Handles both YAML object format and plain string migration.
|
||||
*/
|
||||
class AltText
|
||||
{
|
||||
public const string SOURCE_AI = 'ai';
|
||||
public const string SOURCE_MANUAL = 'manual';
|
||||
public const string SOURCE_REVIEWED = 'reviewed';
|
||||
|
||||
public function __construct(
|
||||
protected string $text = '',
|
||||
protected bool $decorative = false,
|
||||
protected string $source = self::SOURCE_MANUAL,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw field value into an AltText instance.
|
||||
* Handles YAML object format and plain string migration.
|
||||
*/
|
||||
public static function parse(string|null $value): static
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return new static();
|
||||
}
|
||||
|
||||
try {
|
||||
$data = Yaml::decode($value);
|
||||
|
||||
if (is_array($data) && (array_key_exists('text', $data) || array_key_exists('decorative', $data))) {
|
||||
return new static(
|
||||
text: (string)($data['text'] ?? ''),
|
||||
decorative: (bool)($data['decorative'] ?? false),
|
||||
source: (string)($data['source'] ?? self::SOURCE_MANUAL),
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// not valid YAML
|
||||
}
|
||||
|
||||
// plain string = migration from old alt field
|
||||
return new static(text: $value, source: self::SOURCE_MANUAL);
|
||||
}
|
||||
|
||||
public static function fromField(Field $field): static
|
||||
{
|
||||
return static::parse($field->value());
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isDecorative(): bool
|
||||
{
|
||||
return $this->decorative;
|
||||
}
|
||||
|
||||
public function source(): string
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function isMissing(): bool
|
||||
{
|
||||
return !$this->decorative && trim($this->text) === '';
|
||||
}
|
||||
|
||||
public function isAiGenerated(): bool
|
||||
{
|
||||
return $this->source === self::SOURCE_AI;
|
||||
}
|
||||
|
||||
public function isReviewed(): bool
|
||||
{
|
||||
return $this->source === self::SOURCE_REVIEWED;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'text' => $this->text,
|
||||
'decorative' => $this->decorative,
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML attributes for the alt text.
|
||||
*/
|
||||
public function toAttr(): array
|
||||
{
|
||||
return ['alt' => $this->decorative ? '' : $this->text];
|
||||
}
|
||||
|
||||
public function toYaml(): string
|
||||
{
|
||||
return Yaml::encode($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved alt text as a Kirby Field for chaining (.or(), .isNotEmpty(), etc.)
|
||||
*/
|
||||
public function toField(): Field
|
||||
{
|
||||
return new Field(null, 'alt', (string)$this);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->decorative) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
41
site/plugins/kirby-seo/classes/Buttons/RobotsViewButton.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButton;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
class RobotsViewButton extends ViewButton
|
||||
{
|
||||
public function __construct(Page $model)
|
||||
{
|
||||
$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
|
|
@ -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
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
316
site/plugins/kirby-seo/classes/Field/AltTextField.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Field;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Form\Field;
|
||||
use Kirby\Form\FieldClass;
|
||||
use Kirby\Http\Response;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\AltText;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
class AltTextField extends FieldClass
|
||||
{
|
||||
protected bool $ai;
|
||||
protected bool $autogenerate;
|
||||
protected mixed $value = [];
|
||||
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$this->autogenerate = $params['autogenerate'] ?? false;
|
||||
parent::__construct($params);
|
||||
$this->setAi($params['ai'] ?? true);
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return 'alt-text';
|
||||
}
|
||||
|
||||
protected function setAi(bool $ai = true): void
|
||||
{
|
||||
if ($ai && !Seo::option('components.ai')::enabled()) {
|
||||
$ai = false;
|
||||
}
|
||||
|
||||
if ($ai && App::instance()->user()?->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
$ai = false;
|
||||
}
|
||||
|
||||
$this->ai = $ai;
|
||||
}
|
||||
|
||||
public function ai(): bool
|
||||
{
|
||||
return $this->ai;
|
||||
}
|
||||
|
||||
public function autogenerate(): bool
|
||||
{
|
||||
return $this->autogenerate;
|
||||
}
|
||||
|
||||
public function fill(mixed $value): static
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$this->value = $value;
|
||||
} else {
|
||||
$this->value = AltText::parse($value)->toArray();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toFormValue(): mixed
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toStoredValue(): mixed
|
||||
{
|
||||
if (is_array($this->value)) {
|
||||
$altText = new AltText(
|
||||
text: $this->value['text'] ?? '',
|
||||
decorative: $this->value['decorative'] ?? false,
|
||||
source: $this->value['source'] ?? AltText::SOURCE_MANUAL,
|
||||
);
|
||||
|
||||
if ($altText->isMissing()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $altText->toYaml();
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'ai' => $this->ai(),
|
||||
'autogenerate' => $this->autogenerate(),
|
||||
];
|
||||
}
|
||||
|
||||
public function routes(): array
|
||||
{
|
||||
$field = $this;
|
||||
|
||||
return [
|
||||
[
|
||||
'pattern' => 'ai/stream',
|
||||
'method' => 'POST',
|
||||
'action' => function () use ($field) {
|
||||
$kirby = App::instance();
|
||||
$component = Seo::option('components.ai');
|
||||
|
||||
if (!$component::enabled()) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.disabled')
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($kirby->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.permission')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$model = $field->model();
|
||||
|
||||
if (!$model instanceof File || $model->type() !== 'image') {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Field must be on an image file.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$data = $kirby->request()->body()->data();
|
||||
$lang = $kirby->api()->language();
|
||||
|
||||
if ($lang) {
|
||||
$kirby->setCurrentLanguage($lang);
|
||||
}
|
||||
|
||||
// begin SSE stream
|
||||
ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
echo ":ok\n\n";
|
||||
flush();
|
||||
|
||||
$send = static function (array $event): void {
|
||||
echo 'data: ' . json_encode(
|
||||
$event,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
) . "\n\n";
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_flush();
|
||||
}
|
||||
|
||||
flush();
|
||||
};
|
||||
|
||||
try {
|
||||
$kirby->data = [
|
||||
'file' => $model,
|
||||
'site' => $kirby->site(),
|
||||
'kirby' => $kirby,
|
||||
];
|
||||
|
||||
$prompt = trim(snippet('seo/prompts/tasks/alt-text', [
|
||||
'file' => $model,
|
||||
'instructions' => $data['instructions'] ?? null,
|
||||
], return: true));
|
||||
|
||||
$content = [
|
||||
Content::user()
|
||||
->image($model)
|
||||
->text($prompt),
|
||||
];
|
||||
|
||||
foreach ($component::provider()->stream($content) as $chunk) {
|
||||
$send([
|
||||
'type' => $chunk->type,
|
||||
'text' => $chunk->text,
|
||||
'payload' => $chunk->payload,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$send([
|
||||
'type' => 'error',
|
||||
'payload' => [
|
||||
'message' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates alt text for all autogenerate-enabled fields on a file.
|
||||
* Handles both single-lang and multi-lang sites in a single AI call.
|
||||
*/
|
||||
public static function generateForFile(File $file): File
|
||||
{
|
||||
if ($file->type() !== 'image') {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$component = Seo::option('components.ai');
|
||||
if (!$component::enabled()) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$blueprint = $file->blueprint();
|
||||
$autogenerateFields = [];
|
||||
|
||||
foreach ($blueprint->fields() as $name => $field) {
|
||||
$fieldClass = Field::$types[$field['type'] ?? ''] ?? null;
|
||||
if (!is_a($fieldClass, static::class, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($field['autogenerate'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$autogenerateFields[] = $name;
|
||||
}
|
||||
|
||||
if ($autogenerateFields === []) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$kirby = $file->kirby();
|
||||
$languages = $kirby->languages();
|
||||
$isMultiLang = $languages->isNotEmpty();
|
||||
|
||||
$kirby->data = [
|
||||
'file' => $file,
|
||||
'site' => $kirby->site(),
|
||||
'kirby' => $kirby,
|
||||
];
|
||||
|
||||
$langCodes = $isMultiLang
|
||||
? $languages->pluck('code')
|
||||
: [];
|
||||
|
||||
$prompt = trim(snippet('seo/prompts/tasks/alt-text', [
|
||||
'file' => $file,
|
||||
'languages' => $langCodes,
|
||||
], return: true));
|
||||
|
||||
$content = [
|
||||
Content::user()
|
||||
->image($file)
|
||||
->text($prompt),
|
||||
];
|
||||
|
||||
$text = '';
|
||||
foreach ($component::provider()->stream($content) as $chunk) {
|
||||
if ($chunk->type === 'text-delta') {
|
||||
$text .= $chunk->text;
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return $file;
|
||||
}
|
||||
|
||||
// parse into [langCode => altText] map (single-lang uses null key)
|
||||
$results = [];
|
||||
|
||||
if ($isMultiLang) {
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || !str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colonPos = strpos($line, ':');
|
||||
$code = trim(substr($line, 0, $colonPos));
|
||||
$value = trim(substr($line, $colonPos + 1));
|
||||
|
||||
if ($value !== '' && in_array($code, $langCodes, true)) {
|
||||
$results[$code] = $value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$results[null] = $text;
|
||||
}
|
||||
|
||||
return $kirby->impersonate('kirby', function () use ($file, $results, $autogenerateFields) {
|
||||
foreach ($results as $langCode => $altText) {
|
||||
$updates = [];
|
||||
foreach ($autogenerateFields as $name) {
|
||||
$updates[$name] = (new AltText(text: $altText, source: AltText::SOURCE_AI))->toYaml();
|
||||
}
|
||||
|
||||
$file = $file->update($updates, $langCode);
|
||||
}
|
||||
|
||||
return $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
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
35
site/plugins/kirby-seo/classes/Jobs/GenerateAltTextJob.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Jobs;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use tobimori\Queues\Job;
|
||||
use tobimori\Seo\Field\AltTextField;
|
||||
|
||||
/**
|
||||
* Queue job for generating alt text via AI on file upload
|
||||
*/
|
||||
class GenerateAltTextJob extends Job
|
||||
{
|
||||
public function type(): string
|
||||
{
|
||||
return 'seo:generate-alt-text';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Generate Alt Text';
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$fileId = $this->payload()['fileId'];
|
||||
$file = App::instance()->file($fileId);
|
||||
|
||||
if ($file === null) {
|
||||
throw new \Exception("File not found: {$fileId}");
|
||||
}
|
||||
|
||||
AltTextField::generateForFile($file);
|
||||
}
|
||||
}
|
||||
762
site/plugins/kirby-seo/classes/Meta.php
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
<?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:.+/'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Decode HTML entities from a value to prevent double-encoding
|
||||
* when the value is later passed through Html::tag() which applies htmlspecialchars().
|
||||
* This is necessary because Writer fields store content as HTML (e.g. & as &).
|
||||
*/
|
||||
protected static function decodeEntities(mixed $value): mixed
|
||||
{
|
||||
if (is_a($value, Field::class)) {
|
||||
return new Field($value->model(), $value->key(), html_entity_decode($value->value(), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and decode entities
|
||||
array_walk($value, function (&$item) {
|
||||
if (is_callable($item)) {
|
||||
$item = $item($this->page);
|
||||
}
|
||||
$item = self::decodeEntities($item);
|
||||
});
|
||||
|
||||
// add the tag to the array
|
||||
$tags[] = [
|
||||
'tag' => $tag['tag'],
|
||||
'attributes' => $value,
|
||||
'content' => null,
|
||||
'priority' => $tag['priority'] ?? false,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode HTML entities to prevent double-encoding by Html::tag()
|
||||
$value = self::decodeEntities($value);
|
||||
|
||||
// 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 = [
|
||||
'ogDescription' => 'metaDescription',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 = App::instance()->url() . '/sitemap.xsl';
|
||||
$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
|
|
@ -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 = App::instance()->url() . '/sitemap.xsl';
|
||||
$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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
50
site/plugins/kirby-seo/composer.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "tobimori/kirby-seo",
|
||||
"description": "The default choice for SEO on Kirby: Implement technical SEO & Meta best practices with ease and provide an easy-to-use editor experience",
|
||||
"type": "kirby-plugin",
|
||||
"version": "2.0.0-beta.3",
|
||||
"license": "proprietary",
|
||||
"homepage": "https://github.com/tobimori/kirby-seo#readme",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tobias Möritz",
|
||||
"email": "tobias@moeritz.io"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"tobimori\\Seo\\": "classes"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "RC",
|
||||
"require": {
|
||||
"php": ">=8.3.0",
|
||||
"getkirby/composer-installer": "^1.2.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "Rasterize non-resizable images (SVG, etc.) for AI alt text generation",
|
||||
"tobimori/kirby-queues": "Enable background processing support",
|
||||
"getkirby/cli": "Enable background processing support",
|
||||
"spatie/schema-org": "Enable the Schema.org support"
|
||||
},
|
||||
"require-dev": {
|
||||
"getkirby/cli": "^1.8.0",
|
||||
"tobimori/kirby-queues": "^1.0.0-beta.1",
|
||||
"friendsofphp/php-cs-fixer": "^3.48",
|
||||
"spatie/schema-org": "^3.23",
|
||||
"getkirby/cms": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dist": "composer install --no-dev --optimize-autoloader",
|
||||
"fix": "php-cs-fixer fix"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"allow-plugins": {
|
||||
"getkirby/composer-installer": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"kirby-cms-path": false
|
||||
}
|
||||
}
|
||||
145
site/plugins/kirby-seo/config/areas.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use tobimori\Seo\Buttons\RobotsViewButton;
|
||||
use tobimori\Seo\Buttons\UtmShareViewButton;
|
||||
use tobimori\Seo\Dialogs\UtmShareDialog;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'seo' => fn () =>
|
||||
[
|
||||
'buttons' => [
|
||||
'page.robots' => fn (Page $page) => Seo::option('robots.enabled') ? new RobotsViewButton($page) : null,
|
||||
'utm-share' => fn (ModelWithContent $model) => new UtmShareViewButton($model)
|
||||
],
|
||||
'drawers' => [
|
||||
'gsc-data' => [
|
||||
'pattern' => 'seo/gsc/data/(:all)',
|
||||
'load' => function (string $parent) {
|
||||
$kirby = App::instance();
|
||||
$request = $kirby->request();
|
||||
$metric = $request->get('metric', 'clicks');
|
||||
$asc = (bool) $request->get('asc', in_array($metric, ['position', 'query']) ? 1 : 0);
|
||||
$page = max(1, (int) $request->get('page', 1));
|
||||
$limit = max(1, min(100, (int) $request->get('limit', 20)));
|
||||
|
||||
try {
|
||||
$model = Find::parent(ltrim($parent, '/'));
|
||||
} catch (\Exception $e) {
|
||||
return ['component' => 'k-error-drawer', 'props' => ['message' => 'Model not found']];
|
||||
}
|
||||
|
||||
$gsc = Seo::option('components.gsc');
|
||||
if (!$gsc::hasCredentials() || !$gsc::isConnected() || !$gsc::property()) {
|
||||
return ['component' => 'k-error-drawer', 'props' => ['message' => 'GSC not connected']];
|
||||
}
|
||||
|
||||
$title = I18n::translate('seo.sections.searchConsole.title');
|
||||
if ($model instanceof Page) {
|
||||
$title .= ' · ' . $model->title()->value();
|
||||
}
|
||||
|
||||
$data = $gsc::queryForModel($model, $metric, 25000, $asc);
|
||||
$total = count($data);
|
||||
$pageData = array_slice($data, ($page - 1) * $limit, $limit);
|
||||
|
||||
// format numbers with locale
|
||||
$locale = $kirby->panelLanguage();
|
||||
$number = new NumberFormatter($locale, NumberFormatter::DECIMAL);
|
||||
$percent = new NumberFormatter($locale, NumberFormatter::PERCENT);
|
||||
$percent->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 1);
|
||||
$percent->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, 1);
|
||||
$decimal = new NumberFormatter($locale, NumberFormatter::DECIMAL);
|
||||
$decimal->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 1);
|
||||
$decimal->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, 1);
|
||||
|
||||
$rows = array_map(fn ($row) => [
|
||||
'query' => $row['keys'][0],
|
||||
'clicks' => $number->format($row['clicks']),
|
||||
'impressions' => $number->format($row['impressions']),
|
||||
'ctr' => $percent->format($row['ctr']),
|
||||
'position' => $decimal->format($row['position'])
|
||||
], $pageData);
|
||||
|
||||
return [
|
||||
'component' => 'k-gsc-drawer',
|
||||
'props' => [
|
||||
'title' => $title,
|
||||
'icon' => 'google',
|
||||
'parent' => $parent,
|
||||
'metric' => $metric,
|
||||
'sortAsc' => $asc,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => $total,
|
||||
'columns' => [
|
||||
'query' => ['label' => I18n::translate('seo.sections.searchConsole.query'), 'width' => '1/2', 'mobile' => true],
|
||||
'clicks' => ['label' => I18n::translate('seo.sections.searchConsole.clicks'), 'width' => '1/8', 'align' => 'right'],
|
||||
'impressions' => ['label' => I18n::translate('seo.sections.searchConsole.impressions'), 'width' => '1/8', 'align' => 'right'],
|
||||
'ctr' => ['label' => I18n::translate('seo.sections.searchConsole.ctr'), 'width' => '1/8', 'align' => 'right'],
|
||||
'position' => ['label' => I18n::translate('seo.sections.searchConsole.position'), 'width' => '1/8', 'align' => 'right', 'mobile' => true]
|
||||
],
|
||||
'rows' => $rows
|
||||
]
|
||||
];
|
||||
}
|
||||
]
|
||||
],
|
||||
'dialogs' => [
|
||||
'utm-share' => [
|
||||
'pattern' => 'seo/utm-share/(:all)',
|
||||
'controller' => UtmShareDialog::class
|
||||
],
|
||||
'gsc-select-property' => [
|
||||
'pattern' => 'seo/gsc/select-property',
|
||||
'load' => function () {
|
||||
$siteUrl = App::instance()->site()->url();
|
||||
$gsc = Seo::option('components.gsc');
|
||||
|
||||
$properties = $gsc::listProperties();
|
||||
$options = array_map(fn ($p) => [
|
||||
'value' => $p['siteUrl'],
|
||||
'text' => str_starts_with($p['siteUrl'], 'sc-domain:')
|
||||
? substr($p['siteUrl'], 10) . ' (' . I18n::translate('seo.sections.searchConsole.scDomain') . ')'
|
||||
: $p['siteUrl']
|
||||
], $properties);
|
||||
|
||||
$currentProperty = $gsc::property();
|
||||
$defaultProperty = $currentProperty ?? $gsc::findMatchingProperty($siteUrl);
|
||||
|
||||
return [
|
||||
'component' => 'k-form-dialog',
|
||||
'props' => [
|
||||
'fields' => [
|
||||
'property' => [
|
||||
'label' => I18n::translate('seo.sections.searchConsole.selectPropertyLabel'),
|
||||
'type' => 'select',
|
||||
'required' => true,
|
||||
'options' => $options,
|
||||
'empty' => false
|
||||
]
|
||||
],
|
||||
'submitButton' => I18n::translate('select'),
|
||||
'value' => [
|
||||
'property' => $defaultProperty
|
||||
]
|
||||
]
|
||||
];
|
||||
},
|
||||
'submit' => function () {
|
||||
$property = App::instance()->request()->get('property');
|
||||
Seo::option('components.gsc')::setProperty($property);
|
||||
|
||||
return [
|
||||
'event' => 'gsc.propertySelected'
|
||||
];
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
152
site/plugins/kirby-seo/config/fields.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Ai;
|
||||
use tobimori\Seo\Field\AltTextField;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'seo-writer' => [
|
||||
'extends' => 'writer',
|
||||
'computed' => [
|
||||
'placeholder' => function () {
|
||||
if ($this->placeholder === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $this->model()->toString($this->placeholder);
|
||||
|
||||
if (Str::contains($value, 'data-seo-template-variable')) {
|
||||
$value = Str::unhtml($value);
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
['{{ title }}', '{{ site.title }}'],
|
||||
[t('seo.writerNodes.template.title'), t('seo.writerNodes.template.siteTitle')],
|
||||
$value
|
||||
);
|
||||
}
|
||||
],
|
||||
'props' => [
|
||||
/**
|
||||
* Enables/disables the character counter in the top right corner
|
||||
*/
|
||||
'ai' => function (string|bool $ai = false) {
|
||||
if (!Seo::option('components.ai')::enabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check ai permission @see index.php L31
|
||||
if (App::instance()->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $ai;
|
||||
},
|
||||
|
||||
// reset defaults
|
||||
'counter' => fn (bool $counter = false) => $counter, // we have to disable the counter because its at the same place as our ai button
|
||||
'inline' => fn (bool $inline = true) => $inline,
|
||||
'marks' => fn (array|bool|null $marks = false) => $marks,
|
||||
'nodes' => fn (array|bool|null $nodes = false) => $nodes,
|
||||
],
|
||||
'api' => fn () => [
|
||||
[
|
||||
'pattern' => 'ai/stream',
|
||||
'method' => 'POST',
|
||||
'action' => function () {
|
||||
$kirby = $this->kirby();
|
||||
$component = Seo::option('components.ai');
|
||||
|
||||
if (!$component::enabled()) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.disabled')
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($kirby->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.permission')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$data = $kirby->request()->body()->data();
|
||||
$lang = $kirby->api()->language();
|
||||
|
||||
// for site, use homepage
|
||||
$model = $this->field()->model();
|
||||
$page = $model instanceof Page ? $model : $model->homePage();
|
||||
$kirby->site()->visit($page, $lang);
|
||||
if ($lang) {
|
||||
$kirby->setCurrentLanguage($lang);
|
||||
}
|
||||
|
||||
// inject data in snippets / rendering process
|
||||
$kirby->data = [ // TODO: check if we want to access the draft / edited version for $page
|
||||
'page' => $page,
|
||||
'site' => $kirby->site(),
|
||||
'kirby' => $kirby
|
||||
];
|
||||
|
||||
// begin streaming thingy
|
||||
ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
echo ":ok\n\n";
|
||||
flush();
|
||||
|
||||
$send = static function (array $event): void {
|
||||
echo 'data: ' . json_encode(
|
||||
$event,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
) . "\n\n";
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_flush();
|
||||
}
|
||||
|
||||
flush();
|
||||
};
|
||||
|
||||
try {
|
||||
foreach (
|
||||
$component::streamTask($this->field()->ai(), [
|
||||
'instructions' => $data['instructions'] ?? null,
|
||||
'edit' => $data['edit'] ?? null
|
||||
]) as $chunk
|
||||
) {
|
||||
$send([
|
||||
'type' => $chunk->type,
|
||||
'text' => $chunk->text,
|
||||
'payload' => $chunk->payload,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$send([
|
||||
'type' => 'error',
|
||||
'payload' => [
|
||||
'message' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
]
|
||||
]
|
||||
],
|
||||
'alt-text' => AltTextField::class,
|
||||
];
|
||||
86
site/plugins/kirby-seo/config/hooks.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Field\AltTextField;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'system.loadPlugins:after' => function () {
|
||||
if (class_exists('tobimori\Queues\Queues')) {
|
||||
\tobimori\Queues\Queues::register(\tobimori\Seo\Jobs\GenerateAltTextJob::class);
|
||||
}
|
||||
},
|
||||
'file.create:after' => function (File $file) {
|
||||
if ($file->type() !== 'image') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (class_exists('tobimori\Queues\Queues')) {
|
||||
\tobimori\Queues\Queues::push('seo:generate-alt-text', [
|
||||
'fileId' => $file->id(),
|
||||
]);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
return AltTextField::generateForFile($file);
|
||||
},
|
||||
'page.update:after' => function (Page $newPage, Page $oldPage) {
|
||||
// only inject blueprint defaults if the seo tab is present
|
||||
if ($newPage->blueprint()->tab('seo')) {
|
||||
$updates = A::reduce(
|
||||
$newPage->kirby()->option('tobimori.seo.robots.types'),
|
||||
function ($carry, $robots) use ($newPage) {
|
||||
$upper = Str::ucfirst($robots);
|
||||
|
||||
if ($newPage->content()->get("robots{$upper}")->value() === '') {
|
||||
$carry["robots{$upper}"] = 'default';
|
||||
}
|
||||
|
||||
return $carry;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (A::count($updates)) {
|
||||
$newPage = $newPage->update($updates, $newPage->kirby()->languageCode());
|
||||
}
|
||||
}
|
||||
|
||||
if (Seo::option('indexnow.enabled')) {
|
||||
$indexNow = new (Seo::option('components.indexnow'))($newPage);
|
||||
$indexNow->request();
|
||||
}
|
||||
|
||||
return $newPage;
|
||||
},
|
||||
'page.changeStatus:after' => function (Page $newPage, Page $oldPage) {
|
||||
if (Seo::option('indexnow.enabled')) {
|
||||
$indexNow = new (Seo::option('components.indexnow'))($newPage);
|
||||
$indexNow->request();
|
||||
}
|
||||
},
|
||||
'page.changeSlug:after' => function (Page $newPage, Page $oldPage) {
|
||||
if (Seo::option('indexnow.enabled')) {
|
||||
$indexNow = new (Seo::option('components.indexnow'))($newPage);
|
||||
$indexNow->request();
|
||||
}
|
||||
},
|
||||
'page.render:before' => function (string $contentType, array $data, Page $page) {
|
||||
if (!class_exists('Spatie\SchemaOrg\Schema')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (option('tobimori.seo.generateSchema')) {
|
||||
$page->schema('WebSite')
|
||||
->url($page->metadata()->canonicalUrl())
|
||||
->copyrightYear(date('Y'))
|
||||
->description($page->metadata()->metaDescription())
|
||||
->name($page->metadata()->metaTitle())
|
||||
->headline($page->metadata()->title());
|
||||
}
|
||||
},
|
||||
];
|
||||
112
site/plugins/kirby-seo/config/options.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Data\Json;
|
||||
use tobimori\Seo\Meta;
|
||||
use tobimori\Seo\Seo;
|
||||
use tobimori\Seo\Ai;
|
||||
use tobimori\Seo\IndexNow;
|
||||
use tobimori\Seo\SchemaSingleton;
|
||||
use tobimori\Seo\GoogleSearchConsole;
|
||||
|
||||
return [
|
||||
// if you want to extend some of the built-in classes, you can overwrite them using the components config option
|
||||
// and page methods or similar stuff will adapt. full customizability!
|
||||
'components' => [
|
||||
'meta' => Meta::class,
|
||||
'ai' => Ai::class,
|
||||
'indexnow' => IndexNow::class,
|
||||
'schema' => SchemaSingleton::class,
|
||||
'gsc' => GoogleSearchConsole::class,
|
||||
],
|
||||
'cache.searchConsole' => true,
|
||||
'cache.indexnow' => true,
|
||||
'cascade' => [
|
||||
'fields',
|
||||
'programmatic',
|
||||
'parent',
|
||||
'fallbackFields',
|
||||
'site',
|
||||
'options'
|
||||
],
|
||||
'default' => [ // default field values for metadata, format is [field => value]
|
||||
'metaTitle' => fn (Page $page) => $page->title(),
|
||||
'metaTemplate' => '{{ title }} - {{ site.title }}',
|
||||
'ogTemplate' => '{{ title }}',
|
||||
'ogSiteName' => fn (Page $page) => $page->site()->title(),
|
||||
'ogType' => 'website',
|
||||
'ogDescription' => fn (Page $page) => $page->metadata()->metaDescription(),
|
||||
'cropOgImage' => true,
|
||||
'locale' => fn (Page $page) => $page->kirby()->language()?->locale(LC_ALL) ?? Seo::option('locale', 'en_US'),
|
||||
// default for robots: noIndex if global index configuration is set, otherwise fall back to page status
|
||||
'robotsIndex' => function (Page $page) {
|
||||
$index = Seo::option('robots.index');
|
||||
if (!$index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Seo::option('robots.followPageStatus') ? $page->isListed() : true;
|
||||
},
|
||||
'robotsFollow' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||
'robotsArchive' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||
'robotsImageindex' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||
'robotsSnippet' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||
],
|
||||
'socialMedia' => [ // default fields for social media links, format is [field => placeholder]
|
||||
'twitter' => 'https://twitter.com/my-company',
|
||||
'facebook' => 'https://facebook.com/my-company',
|
||||
'instagram' => 'https://instagram.com/my-company',
|
||||
'youtube' => 'https://youtube.com/channel/my-company',
|
||||
'linkedin' => 'https://linkedin.com/company/my-company',
|
||||
'bluesky' => 'https://bsky.app/profile/example.bsky.social',
|
||||
'mastodon' => 'https://mastodon.social/@example'
|
||||
],
|
||||
'previews' => [
|
||||
'google',
|
||||
'facebook',
|
||||
'slack'
|
||||
],
|
||||
'robots' => [
|
||||
'enabled' => true, // whether robots handling should be done by the plugin
|
||||
|
||||
// @deprecated - please use robots.enabled
|
||||
'active' => fn () => Seo::option('sitemap.enabled'),
|
||||
'followPageStatus' => true, // should unlisted pages be noindex by default?
|
||||
'pageSettings' => true, // whether to have robots settings on each page
|
||||
'index' => fn () => !App::instance()->option('debug'), // default site-wide robots setting
|
||||
'sitemap' => null, // sets sitemap url, will be replaced by plugin sitemap in the future
|
||||
'content' => [], // custom robots content
|
||||
'types' => ['index', 'follow', 'archive', 'imageindex', 'snippet'] // available robots types
|
||||
],
|
||||
'sitemap' => [
|
||||
'enabled' => true,
|
||||
// @deprecated - please use sitemap.enabled
|
||||
'active' => fn () => Seo::option('sitemap.enabled'),
|
||||
'redirect' => true, // redirect /sitemap to /sitemap.xml
|
||||
'locale' => 'en',
|
||||
'generator' => require __DIR__ . '/options/sitemap.php',
|
||||
'changefreq' => 'weekly',
|
||||
'groupByTemplate' => false,
|
||||
'excludeTemplates' => ['error'],
|
||||
'priority' => fn (Page $page) => number_format(($page->isHomePage()) ? 1 : max(1 - 0.2 * $page->depth(), 0.2), 1),
|
||||
],
|
||||
'files' => [
|
||||
'parent' => null,
|
||||
'template' => null,
|
||||
],
|
||||
'canonical' => [
|
||||
'base' => null, // base url for canonical links
|
||||
'trailingSlash' => false, // whether to add trailing slashes to canonical URLs (except for files)
|
||||
],
|
||||
'ai' => require __DIR__ . '/options/ai.php',
|
||||
'indexnow' => require __DIR__ . '/options/indexnow.php',
|
||||
'searchConsole' => [
|
||||
'enabled' => true,
|
||||
'credentials' => null,
|
||||
'tokenPath' => fn () => App::instance()->root('config') . '/.gsc-tokens.json'
|
||||
],
|
||||
'generateSchema' => true, // whether to generate default schema.org data
|
||||
'locale' => 'en_US', // default locale, used for single-language sites
|
||||
'dateFormat' => null, // custom date format
|
||||
];
|
||||
39
site/plugins/kirby-seo/config/options/ai.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Ai\Drivers\Anthropic;
|
||||
use tobimori\Seo\Ai\Drivers\Gemini;
|
||||
use tobimori\Seo\Ai\Drivers\OpenAi;
|
||||
|
||||
// TODO: custom provider per task
|
||||
return [
|
||||
'enabled' => true,
|
||||
'provider' => 'openai',
|
||||
'providers' => [
|
||||
'openai' => [
|
||||
'driver' => OpenAi::class,
|
||||
'config' => [
|
||||
'apiKey' => '', // needs to be defined
|
||||
],
|
||||
],
|
||||
'anthropic' => [
|
||||
'driver' => Anthropic::class,
|
||||
'config' => [
|
||||
'apiKey' => '', // needs to be defined
|
||||
],
|
||||
],
|
||||
'gemini' => [
|
||||
'driver' => Gemini::class,
|
||||
'config' => [
|
||||
'apiKey' => '', // needs to be defined
|
||||
],
|
||||
],
|
||||
'openrouter' => [
|
||||
'driver' => OpenAi::class,
|
||||
'config' => [
|
||||
'apiKey' => '', // needs to be defined
|
||||
'model' => 'openai/gpt-5-nano',
|
||||
'endpoint' => 'https://openrouter.ai/api/v1/responses',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
29
site/plugins/kirby-seo/config/options/indexnow.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'searchEngine' => 'https://api.indexnow.org', // one will propagate to all others. so this is fine @see https://www.indexnow.org/faq
|
||||
'rules' => [
|
||||
// by default, only the current page is requested to be indexed (if indexable: robots allow + listed status)
|
||||
// however you might want to index other pages as well. for example, the 'blog overview' page should always be reindexed when a new 'blog post' is indexed
|
||||
//
|
||||
// syntax: 'match pattern' => ['invalidation rules']
|
||||
//
|
||||
// match patterns:
|
||||
// - '/blog/*' - url pattern (glob or regex)
|
||||
// - 'article' - template name
|
||||
// - '*' - wildcard, matches all pages
|
||||
//
|
||||
// invalidation rules:
|
||||
// - 'parent' => true (direct parent) or number (levels up)
|
||||
// - 'children' => true (all descendants) or number (depth limit)
|
||||
// - 'siblings' => true (all siblings at same level)
|
||||
// - 'urls' => ['/shop', '/'] (specific urls to invalidate)
|
||||
// - 'templates' => ['category', 'shop'] (invalidate all pages with these templates)
|
||||
//
|
||||
// examples:
|
||||
// '/blog/*' => ['parent' => true],
|
||||
// 'article' => ['parent' => 2, 'urls' => ['/blog', '/']],
|
||||
// 'product' => ['parent' => true, 'siblings' => true, 'templates' => ['category']],
|
||||
],
|
||||
];
|
||||
55
site/plugins/kirby-seo/config/options/sitemap.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||
use tobimori\Seo\Meta;
|
||||
|
||||
return function (SitemapIndex $sitemap) {
|
||||
$exclude = option('tobimori.seo.sitemap.excludeTemplates', []);
|
||||
$pages = site()->index()->filter(fn ($page) => $page->metadata()->robotsIndex()->toBool() && !in_array($page->intendedTemplate()->name(), $exclude));
|
||||
|
||||
if ($group = option('tobimori.seo.sitemap.groupByTemplate')) {
|
||||
$pages = $pages->group('intendedTemplate');
|
||||
}
|
||||
|
||||
if (is_a($pages->first(), 'Kirby\Cms\Page')) {
|
||||
$pages = $pages->group(fn () => 'pages');
|
||||
}
|
||||
|
||||
foreach ($pages as $group) {
|
||||
$index = $sitemap->create($group ? $group->first()->intendedTemplate()->name() : 'pages');
|
||||
|
||||
foreach ($group as $page) {
|
||||
$url = $index->createUrl($page->metadata()->canonicalUrl())
|
||||
->lastmod($page->modified() ?? (int)(date('c')))
|
||||
->changefreq(is_callable($changefreq = option('tobimori.seo.sitemap.changefreq')) ? $changefreq($page) : $changefreq)
|
||||
->priority(is_callable($priority = option('tobimori.seo.sitemap.priority')) ? $priority($page) : $priority);
|
||||
|
||||
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
|
||||
$alternates = [];
|
||||
foreach (kirby()->languages() as $language) {
|
||||
// only if this language is translated for this page and exists
|
||||
if ($page->translation($language->code())->exists()) {
|
||||
/*
|
||||
* Specification: "lists every alternate version of the page, including itself."
|
||||
* https://developers.google.com/search/docs/specialty/international/localized-versions#sitemap
|
||||
*/
|
||||
$alternates[] =
|
||||
[
|
||||
'hreflang' => Meta::toBCP47($language),
|
||||
'href' => $page->url($language->code()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// add x-default
|
||||
$alternates[] =
|
||||
[
|
||||
'hreflang' => 'x-default',
|
||||
'href' => $page->indexUrl(),
|
||||
];
|
||||
|
||||
$url->alternates($alternates);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
40
site/plugins/kirby-seo/config/page-methods.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use tobimori\Seo\SchemaSingleton;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'schema' => fn ($type) => Seo::option('components.schema')::getInstance($type, $this),
|
||||
'schemas' => fn () => Seo::option('components.schema')::getInstances($this),
|
||||
'metadata' => fn (?Language $lang = null) => new (Seo::option('components.meta'))($this, $lang),
|
||||
'robots' => fn (?Language $lang = null) => $this->metadata($lang)->robots(),
|
||||
'indexUrl' => function () {
|
||||
// Google: "fallback page for unmatched languages, especially on language/country selectors or auto-redirecting home pages."
|
||||
// https://developers.google.com/search/docs/specialty/international/localized-versions#all-method-guidelines
|
||||
|
||||
// returns the index URL of the site, e.g. https://example.com/
|
||||
$kirbyUrl = $this->kirby()->url('index');
|
||||
|
||||
$defaultLang = $this->kirby()->defaultLanguage()?->code();
|
||||
// returns the site URL, e.g. https://example.com/en
|
||||
// we have to request the default language so we don't get localized slugs
|
||||
$siteUrl = $this->site()->url($defaultLang);
|
||||
|
||||
// returns the full URL of the current page in the default language, e.g. https://example.com/en/about
|
||||
// again, request default language otherwise there is a mismatch in language prefix between the site URL and the current page URL
|
||||
$thisUrl = $this->url($defaultLang);
|
||||
|
||||
// remove the part form the URL that is specific to the 'site'
|
||||
// this is usually the language code prefix
|
||||
// https://example.com/en/ + https://example.com/en/about -> https://example.com/about
|
||||
if (strpos($siteUrl, $kirbyUrl) === 0 && strlen($siteUrl) > strlen($kirbyUrl)) {
|
||||
if (strpos($thisUrl, $siteUrl) === 0) {
|
||||
$pathAfterSite = substr($thisUrl, strlen($siteUrl));
|
||||
return "{$kirbyUrl}{$pathAfterSite}";
|
||||
}
|
||||
}
|
||||
|
||||
return $thisUrl;
|
||||
},
|
||||
];
|
||||
281
site/plugins/kirby-seo/config/routes.php
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Data\Json;
|
||||
use tobimori\Seo\Seo;
|
||||
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||
|
||||
return [
|
||||
[
|
||||
'pattern' => 'indexnow-(:any).txt',
|
||||
'method' => 'GET',
|
||||
'action' => function (string $key) {
|
||||
if (Seo::option('indexnow.enabled') && Seo::option('components.indexnow')::verifyKey($key)) {
|
||||
return new Response($key, 'text/plain', 200);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
'pattern' => 'robots.txt',
|
||||
'method' => 'GET|HEAD',
|
||||
'action' => function () {
|
||||
if (Seo::option('robots.active')) {
|
||||
$content = snippet('seo/robots.txt', [], true);
|
||||
return new Response($content, 'text/plain', 200);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'robots.txt',
|
||||
'method' => 'OPTIONS',
|
||||
'action' => function () {
|
||||
if (Seo::option('robots.active')) {
|
||||
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'robots.txt',
|
||||
'method' => 'ALL',
|
||||
'action' => function () {
|
||||
if (Seo::option('robots.active')) {
|
||||
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
'pattern' => 'sitemap',
|
||||
'method' => 'GET|HEAD',
|
||||
'action' => function () {
|
||||
if (!Seo::option('sitemap.redirect') || !Seo::option('sitemap.active')) {
|
||||
$this->next();
|
||||
}
|
||||
|
||||
go('/sitemap.xml');
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap',
|
||||
'method' => 'OPTIONS',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap',
|
||||
'method' => 'ALL',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
'pattern' => 'sitemap.xsl',
|
||||
'method' => 'GET',
|
||||
'action' => function () {
|
||||
if (!Seo::option('sitemap.active')) {
|
||||
$this->next();
|
||||
}
|
||||
|
||||
kirby()->response()->type('text/xsl');
|
||||
|
||||
$lang = Seo::option('sitemap.locale', 'en');
|
||||
kirby()->setCurrentTranslation($lang);
|
||||
|
||||
return Page::factory([
|
||||
'slug' => 'sitemap',
|
||||
'template' => 'sitemap',
|
||||
'model' => 'sitemap',
|
||||
'content' => [
|
||||
'title' => t('seo.sitemap.title'),
|
||||
],
|
||||
])->render(contentType: 'xsl');
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap.xsl',
|
||||
'method' => 'OPTIONS',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('', 'text/plain', 204, ['Allow' => 'GET']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap.xsl',
|
||||
'method' => 'ALL',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
'pattern' => 'sitemap.xml',
|
||||
'method' => 'GET|HEAD',
|
||||
'action' => function () {
|
||||
if (!Seo::option('sitemap.active', true)) {
|
||||
$this->next();
|
||||
}
|
||||
|
||||
SitemapIndex::instance()->generate();
|
||||
kirby()->response()->type('text/xml');
|
||||
return Page::factory([
|
||||
'slug' => 'sitemap',
|
||||
'template' => 'sitemap',
|
||||
'model' => 'sitemap',
|
||||
'content' => [
|
||||
'title' => t('seo.sitemap.title'),
|
||||
'index' => null,
|
||||
],
|
||||
])->render(contentType: 'xml');
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap.xml',
|
||||
'method' => 'OPTIONS',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active', true)) {
|
||||
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap.xml',
|
||||
'method' => 'ALL',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active', true)) {
|
||||
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
'pattern' => 'sitemap-(:any).xml',
|
||||
'method' => 'GET|HEAD',
|
||||
'action' => function (string $index) {
|
||||
if (!Seo::option('sitemap.active', true)) {
|
||||
$this->next();
|
||||
}
|
||||
|
||||
SitemapIndex::instance()->generate();
|
||||
if (!SitemapIndex::instance()->isValidIndex($index)) {
|
||||
$this->next();
|
||||
}
|
||||
|
||||
kirby()->response()->type('text/xml');
|
||||
return Page::factory([
|
||||
'slug' => "sitemap-{$index}",
|
||||
'template' => 'sitemap',
|
||||
'model' => 'sitemap',
|
||||
'content' => [
|
||||
'title' => t('seo.sitemap.title'),
|
||||
'index' => $index,
|
||||
],
|
||||
])->render(contentType: 'xml');
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap-(:any).xml',
|
||||
'method' => 'OPTIONS',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('', 'text/plain', 204, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => 'sitemap-(:any).xml',
|
||||
'method' => 'ALL',
|
||||
'action' => function () {
|
||||
if (Seo::option('sitemap.active')) {
|
||||
return new Response('Method Not Allowed', 'text/plain', 405, ['Allow' => 'GET, HEAD']);
|
||||
}
|
||||
|
||||
$this->next();
|
||||
}
|
||||
],
|
||||
|
||||
// Google Search Console OAuth
|
||||
[
|
||||
'pattern' => '__seo/gsc/auth',
|
||||
'method' => 'GET',
|
||||
'action' => function () {
|
||||
$kirby = App::instance();
|
||||
if (!$kirby->user() || !Seo::option('searchConsole.enabled') || !Seo::option('components.gsc')::hasCredentials()) {
|
||||
go($kirby->site()->panel()->url());
|
||||
}
|
||||
|
||||
$return = $kirby->request()->get('return') ?? $kirby->site()->panel()->url();
|
||||
$state = base64_encode(Json::encode([
|
||||
'csrf' => bin2hex(random_bytes(16)),
|
||||
'return' => $return
|
||||
]));
|
||||
|
||||
$redirectUri = rtrim($kirby->url(), '/') . '/__seo/gsc/callback';
|
||||
go(Seo::option('components.gsc')::authUrl($redirectUri, $state));
|
||||
}
|
||||
],
|
||||
[
|
||||
'pattern' => '__seo/gsc/callback',
|
||||
'method' => 'GET',
|
||||
'action' => function () {
|
||||
$kirby = App::instance();
|
||||
if (!$kirby->user()) {
|
||||
go($kirby->site()->panel()->url());
|
||||
}
|
||||
|
||||
$request = $kirby->request();
|
||||
$state = Json::decode(base64_decode($request->get('state')));
|
||||
if (!$state || empty($state['csrf'])) {
|
||||
throw new \Exception('Invalid OAuth state');
|
||||
}
|
||||
|
||||
if ($error = $request->get('error')) {
|
||||
throw new \Exception("OAuth error: {$error}");
|
||||
}
|
||||
|
||||
if (!($code = $request->get('code'))) {
|
||||
throw new \Exception('No authorization code received');
|
||||
}
|
||||
|
||||
$redirectUri = rtrim($kirby->url(), '/') . '/__seo/gsc/callback';
|
||||
Seo::option('components.gsc')::exchangeCode($code, $redirectUri);
|
||||
|
||||
// redirect back to where the user came from
|
||||
$return = $state['return'] ?? $kirby->site()->panel()->url();
|
||||
go($return);
|
||||
}
|
||||
],
|
||||
];
|
||||
134
site/plugins/kirby-seo/config/sections.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'seo-preview' => [
|
||||
'mixins' => ['headline'],
|
||||
'computed' => [
|
||||
'options' => fn () => A::map(option('tobimori.seo.previews'), fn ($item) => [
|
||||
'value' => $item,
|
||||
'text' => t("seo.sections.preview.{$item}")
|
||||
]),
|
||||
'meta' => function () {
|
||||
$model = $this->model();
|
||||
|
||||
if ($model instanceof Site || $model instanceof Page) {
|
||||
// clone the model with the content from the changes version
|
||||
$changesVersion = $model->version('changes');
|
||||
if ($changesVersion->exists('current')) {
|
||||
$model = $model->clone(['content' => $changesVersion->content()->toArray()]);
|
||||
}
|
||||
|
||||
// if it's a site, fall back to the home page for preview data
|
||||
$model = $model instanceof Site ? $model->homePage() : $model;
|
||||
if (!$model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = $model->metadata();
|
||||
return [
|
||||
'page' => $model->slug(),
|
||||
'url' => $model->url(),
|
||||
'pageTitle' => Str::unhtml($model->title()->value()),
|
||||
'title' => Str::unhtml($meta->metaTitle()->value()),
|
||||
'description' => Str::unhtml($meta->metaDescription()->value()),
|
||||
'ogSiteName' => Str::unhtml($meta->ogSiteName()->value()),
|
||||
'ogTitle' => Str::unhtml($meta->ogTitle()->value()),
|
||||
'ogDescription' => Str::unhtml($meta->ogDescription()->value()),
|
||||
'ogImage' => $meta->ogImage(),
|
||||
'cropOgImage' => $meta->cropOgImage()->toBool(),
|
||||
'panelUrl' => method_exists($model, 'panel') ? "{$model->panel()?->url()}?tab=seo" : null,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
]
|
||||
],
|
||||
'heading-structure' => [
|
||||
'mixins' => ['headline'],
|
||||
'computed' => [
|
||||
'data' => function () {
|
||||
$model = $this->model();
|
||||
if (!($model instanceof Page)) {
|
||||
// only works for pages (not site, files, etc.)
|
||||
return [];
|
||||
}
|
||||
|
||||
// In Kirby 5, use the changes version if it exists
|
||||
// clone the model with the content from the changes version
|
||||
$changesVersion = $model->version('changes');
|
||||
if ($changesVersion->exists('current')) {
|
||||
$model = $model->clone(['content' => $changesVersion->content()->toArray()]);
|
||||
}
|
||||
|
||||
// Render the page
|
||||
$page = $model->render();
|
||||
$dom = new DOMDocument();
|
||||
$dom->loadHTML(htmlspecialchars_decode(mb_convert_encoding(htmlentities($page, ENT_COMPAT, 'UTF-8'), 'ISO-8859-1', 'UTF-8'), ENT_QUOTES), libxml_use_internal_errors(true));
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
|
||||
$data = [];
|
||||
|
||||
foreach ($headings as $heading) {
|
||||
$data[] = [
|
||||
'level' => (int)str_replace('h', '', $heading->nodeName),
|
||||
'text' => $heading->textContent,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
]
|
||||
],
|
||||
'seo-search-console' => [
|
||||
'mixins' => ['headline'],
|
||||
'computed' => [
|
||||
'status' => function () {
|
||||
if (!Seo::option('components.gsc')::hasCredentials()) {
|
||||
return 'NO_CREDENTIALS';
|
||||
}
|
||||
|
||||
if (!Seo::option('components.gsc')::isConnected()) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
if (!Seo::option('components.gsc')::property()) {
|
||||
return 'SELECT_PROPERTY';
|
||||
}
|
||||
|
||||
return 'CONNECTED';
|
||||
},
|
||||
'property' => fn () => Seo::option('components.gsc')::property(),
|
||||
'pageUrl' => function () {
|
||||
$model = $this->model();
|
||||
if ($model instanceof Page) {
|
||||
return '/' . $model->uri();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
'data' => function () {
|
||||
$gsc = Seo::option('components.gsc');
|
||||
if (!$gsc::hasCredentials() || !$gsc::isConnected() || !$gsc::property()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metric = kirby()->request()->get('metric', 'clicks');
|
||||
$limit = (int) kirby()->request()->get('limit', 10);
|
||||
$asc = in_array($metric, ['position', 'query']);
|
||||
|
||||
try {
|
||||
return $gsc::queryForModel($this->model(), $metric, $limit, $asc);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
];
|
||||
48
site/plugins/kirby-seo/config/site-methods.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Http\Url;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'schema' => fn ($type) => Seo::option('components.schema')::getInstance($type),
|
||||
'schemas' => fn () => Seo::option('components.schema')::getInstances(),
|
||||
'lang' => fn () => Seo::option('components.meta')::normalizeLocale(Seo::option('default.locale', args: [$this->homePage()]), '-'),
|
||||
'canonicalFor' => function (string $url, bool $useRootUrl = false) {
|
||||
// Determine the base URL
|
||||
$base = Seo::option('canonical.base', Seo::option('canonicalBase'));
|
||||
if (!$base) {
|
||||
// If useRootUrl is true or this is a multilang site requesting root URL, use kirby()->url()
|
||||
if ($useRootUrl && kirby()->multilang()) {
|
||||
$base = kirby()->url();
|
||||
} else {
|
||||
$base = $this->url();
|
||||
}
|
||||
}
|
||||
|
||||
if (Str::startsWith($url, $base)) {
|
||||
$canonicalUrl = $url;
|
||||
} else {
|
||||
$path = Url::path($url);
|
||||
$canonicalUrl = url($base . '/' . $path);
|
||||
}
|
||||
|
||||
$trailingSlash = Seo::option('canonical.trailingSlash', false);
|
||||
if ($trailingSlash) {
|
||||
// check if URL has a file extension (like .xml, .jpg, .pdf, etc.)
|
||||
$path = parse_url($canonicalUrl, PHP_URL_PATH) ?? '';
|
||||
$pathInfo = pathinfo($path);
|
||||
$hasExtension = !empty($pathInfo['extension'] ?? null);
|
||||
|
||||
// Only add trailing slash if:
|
||||
// - URL doesn't already have one
|
||||
// - URL doesn't have a file extension
|
||||
// - URL isn't just the base domain
|
||||
if (!Str::endsWith($canonicalUrl, '/') && !$hasExtension && $canonicalUrl !== $base) {
|
||||
$canonicalUrl .= '/';
|
||||
}
|
||||
}
|
||||
|
||||
return $canonicalUrl;
|
||||
}
|
||||
];
|
||||
141
site/plugins/kirby-seo/docs/0_getting-started/0_quickstart.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
title: Quickstart
|
||||
intro: "All you need to get started with Kirby SEO: Installation & initial configuration"
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
Kirby SEO requires
|
||||
|
||||
- Kirby 5 or later
|
||||
- PHP 8.3, 8.4 or 8.5
|
||||
|
||||
Composer is required for full feature support (e.g. schema.org support, background queuing). [Composer](https://getcomposer.org/) is a dependency manager for PHP. If you have never used Composer before, follow the instruction on the [Composer website](https://getcomposer.org/doc/00-intro.md).
|
||||
|
||||
## Installing Kirby SEO
|
||||
|
||||
In a terminal window, navigate to the folder of your Kirby installation. Then run the following command:
|
||||
|
||||
```bash
|
||||
composer require tobimori/kirby-seo
|
||||
```
|
||||
|
||||
Some features require additional packages. Install them when you need them:
|
||||
|
||||
- [Schema.org](2_customization/08_schema-org) requires `spatie/schema-org`
|
||||
- Background Processing (coming soon)
|
||||
|
||||
<details>
|
||||
<summary>Manual Installation</summary>
|
||||
|
||||
If you prefer not to use Composer, you can manually install Kirby SEO. Go to the [GitHub releases page](https://github.com/tobimori/kirby-seo/releases) and find the latest release. Click on "Assets" to expand it and select "Source code (zip)". Extract the contents of the zip file into the `site/plugins/kirby-seo` folder of your Kirby installation.
|
||||
|
||||
</details>
|
||||
|
||||
## Add meta tags to your site
|
||||
|
||||
Kirby SEO needs two snippets in your HTML: one in the `<head>` for meta tags, and one before `</body>` for structured data.
|
||||
|
||||
Find the place in your code where you output the `<head>` tag, this is usually a shared snippet like `header.php` or a layout file. Add the `seo/head` snippet to your `<head>` and the `seo/schemas` snippet before the `</body>` closing tag:
|
||||
|
||||
```php
|
||||
<html lang="<?= $site->lang() ?>">
|
||||
<head>
|
||||
<?php snippet('seo/head'); ?>
|
||||
</head>
|
||||
<body>
|
||||
[...]
|
||||
<?php snippet('seo/schemas'); ?>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Make sure your `<html>` tag also includes the `lang` attribute as shown above. Browsers use it for automatic hyphenation, and Google uses it to determine the language of your page.
|
||||
|
||||
Now open your site in a browser and view the page source. You should already see `<title>`, `<meta>` and Open Graph tags in your `<head>`. The plugin fills them with sensible defaults out of the box.
|
||||
|
||||
## Editing meta tags in the panel
|
||||
|
||||
Next, you want to give your editors control over the SEO fields. Add the SEO tab to your site blueprint:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/site.yml
|
||||
tabs:
|
||||
content:
|
||||
fields:
|
||||
# move your existing fields here
|
||||
seo: seo # <--- add this
|
||||
```
|
||||
|
||||
This gives you global defaults for meta titles, descriptions and social images. Any page without its own SEO settings will use these.
|
||||
Learn more about how defaults work in [Your first Meta Tags](0_getting-started/1_your-first-meta-tags).
|
||||
|
||||
And now add the SEO tab to any page blueprint where editors should be able to override the defaults:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/pages/default.yml
|
||||
tabs:
|
||||
content:
|
||||
fields:
|
||||
# move your existing fields here
|
||||
seo: seo # <--- add this
|
||||
```
|
||||
|
||||
Open the Panel and navigate to any page. You'll see a new SEO tab with fields for meta title, description, social images and more.
|
||||
|
||||
Try it: enter a custom meta title, save, and reload the page in your browser. View the source, your title is there.
|
||||
|
||||
Now delete the title you just entered and reload again. The plugin falls back to your page's regular title.
|
||||
|
||||
This is the **Meta Cascade**, the plugin always finds the best available value, so you only need to fill in fields when you want to override the default. [Learn more about the Meta Cascade](0_getting-started/1_your-first-meta-tags).
|
||||
|
||||
## Set your canonical URL
|
||||
|
||||
To prevent duplicate content issues (e.g. if your site is reachable with and without `www`), tell the plugin which URL is the canonical one:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
// [...]
|
||||
'tobimori.seo' => [
|
||||
'canonical' => [
|
||||
'base' => 'https://www.example.com',
|
||||
],
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
Reload your page and check the source. You'll see a `<link rel="canonical">` tag pointing to your configured domain.
|
||||
|
||||
## Single-language setup
|
||||
|
||||
If you're not using Kirby's [multi-language feature](https://getkirby.com/docs/guide/languages), set your language code so the plugin can generate the correct `og:locale` tag:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
// [...]
|
||||
'tobimori.seo' => [
|
||||
'canonical' => [
|
||||
'base' => 'https://www.example.com',
|
||||
],
|
||||
'locale' => 'en_US',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
If you already added the canonical config above, add `lang` to the same `tobimori.seo` block.
|
||||
|
||||
If you already have multi-language set up in Kirby the plugin will pick up the language automatically.
|
||||
|
||||
## Purchase license & activate your installation
|
||||
|
||||
Once you publish your website, you need to purchase a Kirby SEO license. We will send you a unique license code for your domain. You can activate your license with the following steps:
|
||||
|
||||
1. Open the Panel at `https://example.com/panel` and log in.
|
||||
2. Click on the "Metadata & SEO" tab, and click on "Activate" in the top right.
|
||||
3. Enter your license code and your email address and press "Activate".
|
||||
|
||||
It is not required to activate your license locally.
|
||||
|
||||
## Where to go from here
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
title: Your First Meta Tags
|
||||
intro: Learn how Kirby SEO decides which meta data to show and how to control it at every level
|
||||
---
|
||||
|
||||
In the Quickstart, you installed Kirby SEO and saw meta tags appear in your source code. Now let's look at how to control what shows up, and where.
|
||||
|
||||
## Start with a site-wide default
|
||||
|
||||
Open the Panel, and click on "Metadata & SEO". You'll see something like this:
|
||||
|
||||

|
||||
|
||||
Quite empty, but these are your global defaults. Every page that doesn't have its own meta data will use what you set here.
|
||||
|
||||
### Meta title templates
|
||||
|
||||
You probably don't want to write a custom meta title for every page. Kirby SEO lets you define a **title template** at the site level.
|
||||
|
||||
Go to the Site SEO tab and find the title template field. Click the buttons to insert placeholders like **Page Title** or **Site Title**, and type any separator you want between them.
|
||||
|
||||
A template like `Page Title | Site Title` turns a page called "About" on a site called "My Blog" into:
|
||||
|
||||
```
|
||||
About | My Blog
|
||||
```
|
||||
|
||||
Every page that doesn't have a custom meta title will use this pattern automatically.
|
||||
|
||||
### Set a default description
|
||||
|
||||
Below that, you'll find the Page Description field. Enter a default description like "A blog about good food." and save.
|
||||
|
||||
Open any page on your site and view the source. Every page shows this description, because no page has its own yet.
|
||||
|
||||
### Override on a single page
|
||||
|
||||
Navigate to a specific page in the Panel, and open its Metadata & SEO tab. You'll find a slightly different interface than the site tab:
|
||||
|
||||

|
||||
|
||||
Enter a different description. Save and reload that page in the browser.
|
||||
|
||||
That page now shows its own description. Every other page still shows "A blog about good food."
|
||||
|
||||
### Remove the override
|
||||
|
||||
Delete the description you just entered on the page and save. Reload — the page falls back to the site-wide default again.
|
||||
|
||||
What you just experienced is the **Meta Cascade**. Kirby SEO looks for values in multiple places and uses the most specific one it finds:
|
||||
|
||||
1. **Page fields**: the Metadata & SEO tab on a specific page
|
||||
2. **Programmatic content**: values set in a Page Model via `metaDefaults()`
|
||||
3. **Parent page**: inherited from the parent page (if enabled)
|
||||
4. **Fallback fields**: Open Graph tags fall back to their Meta counterparts
|
||||
5. **Site globals**: the Metadata & SEO tab on the Site
|
||||
6. **Plugin defaults**
|
||||
|
||||
The idea is simple: you set sensible defaults once at the site level, and only override where you need something different. Most pages will never need more than a description in their Metadata & SEO tab.
|
||||
|
||||
## Inheriting settings
|
||||
|
||||
So far you've seen two levels: site defaults and page overrides. But what if you have a section of your site — like a blog — where all pages should share specific settings that are different from the rest of your site?
|
||||
|
||||
Open a page's Metadata & SEO tab and use the Inherit settings field. Select which settings should be passed down to its child pages: title templates, descriptions, Open Graph, robots directives, or all at once. Child pages can still override anything individually.
|
||||
|
||||

|
||||
|
||||
## Open Graph & Social
|
||||
|
||||
When someone shares a link to your site on Facebook, Mastodon, Slack or WhatsApp, these platforms look for Open Graph tags in your HTML to build a preview card. The [Open Graph Protocol](https://ogp.me/) is a standard originally created by Facebook that defines how a page's title, description and image appear when shared.
|
||||
|
||||
Kirby SEO generates these tags automatically. The SEO tab has separate fields for Open Graph titles, descriptions and images, but you usually don't need to fill them in. If you don't set an OG title, the plugin uses your meta title. If you don't set an OG image, it uses the default from your site settings.
|
||||
|
||||
Set a default OG image in the Site SEO tab so every shared link has a preview image, even if you don't set one per page.
|
||||
|
||||
## What's next
|
||||
|
||||
You now know how to control your meta tags, title templates and social previews. The rest of the docs cover individual features in detail:
|
||||
|
||||
// TODO: add links
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 118 KiB |
127
site/plugins/kirby-seo/docs/1_features/00_robots-indexing.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
---
|
||||
title: Robots & Indexing
|
||||
intro: How your pages appear (or don't) in search results
|
||||
---
|
||||
|
||||
Search engines and AI providers use programs called crawlers to discover and index pages on the web. You can tell these crawlers which pages they're allowed to index and which ones they should skip. These are not hard blocks: crawlers don't _have_ to follow them. But all major search engines do respect them.
|
||||
|
||||
Kirby SEO does this in two ways: a global `robots.txt` file and per-page `<meta name="robots">` tags. Both are generated automatically. Most of the indexing control happens through meta tags, while `robots.txt` acts as a global safety net.
|
||||
|
||||
## robots.txt
|
||||
|
||||
Kirby SEO generates a `robots.txt` automatically. You don't need to create or maintain it yourself. Visit `example.com/robots.txt` to see yours.
|
||||
|
||||
By default, the output looks like this:
|
||||
|
||||
```txt
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /panel
|
||||
|
||||
Sitemap: https://example.com/sitemap.xml
|
||||
```
|
||||
|
||||
- `User-agent: *` applies the rules to all crawlers.
|
||||
- `Allow: /` permits crawling the entire site.
|
||||
- `Disallow: /panel` blocks the Kirby Panel from being crawled.
|
||||
- `Sitemap:` points crawlers to your sitemap (only shown when the [sitemap feature](1_features/01_sitemap) is active).
|
||||
|
||||
The `robots.txt` does **not** list individual pages. It only sets broad rules. To control indexing for a specific page, you need meta tags (see below).
|
||||
|
||||
### Debug mode
|
||||
|
||||
When Kirby's debug mode is on, the `robots.txt` blocks all crawlers:
|
||||
|
||||
```txt
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
This way your development or staging site doesn't end up in search results.
|
||||
|
||||
If you need to customize the `robots.txt`, see [Customizing robots.txt](2_customization/02_robots-txt).
|
||||
|
||||
## Robots meta tags
|
||||
|
||||
The `<meta name="robots">` tag tells search engines what to do with a specific page: whether to index it, follow its links, and more.
|
||||
|
||||
Kirby SEO adds this tag to every page automatically.
|
||||
|
||||
### Default behavior
|
||||
|
||||
The plugin follows page status in Kirby:
|
||||
|
||||
- **Listed** pages are visible to search engines
|
||||
- **Unlisted** pages are hidden from search engines
|
||||
- **Draft** pages are not publicly accessible
|
||||
|
||||
In debug mode, **all** pages are hidden from search engines regardless of their status.
|
||||
|
||||
### Overriding robots settings
|
||||
|
||||
Robots meta tags follow the same [Meta Cascade](0_getting-started/1_your-first-meta-tags) as all other fields. The defaults above kick in when nothing else is set, so you can override them:
|
||||
|
||||
- Set a page's robots fields in its **Metadata & SEO** tab to override just that page.
|
||||
- Set the robots fields on the **Site** to override all pages at once.
|
||||
|
||||
One thing to watch out for: if you hard-set a value at the site level (e.g. setting "Index" to "No" instead of leaving it on "Default"), every page without its own override will follow that setting through the cascade. Leave fields on "Default" if you want the plugin to decide based on page status.
|
||||
|
||||
### Robots indicator in the Panel
|
||||
|
||||
Kirby SEO has a page view button that shows the current robots status at a glance. You need to add it to your page blueprints manually:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/pages/default.yml
|
||||
buttons:
|
||||
- open
|
||||
- preview
|
||||
- "-"
|
||||
- settings
|
||||
- languages
|
||||
- status
|
||||
- robots
|
||||
```
|
||||
|
||||
The indicator has three states:
|
||||
|
||||
- **Green**: the page is visible to search engines
|
||||
- **Yellow**: the page is indexed, but with some restrictions
|
||||
- **Red**: the page is hidden from search engines
|
||||
|
||||

|
||||
|
||||
Clicking it takes you straight to the SEO tab, so you can quickly spot which pages are excluded from search engines.
|
||||
|
||||
### Disabling per-page robots fields
|
||||
|
||||

|
||||
|
||||
Our suggestion: hide the per-page robots fields unless you actually need them. The defaults are good enough for the vast majority of sites, and the individual settings tend to confuse editors more than they help. You can disable them entirely:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'robots' => [
|
||||
'pageSettings' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
This hides the robots fields on both page and site level. The defaults (based on page status and debug mode) still apply.
|
||||
|
||||
For more ways to customize robots behavior, see [Customizing robots.txt](2_customization/02_robots-txt).
|
||||
|
||||
<details>
|
||||
<summary>Tags suppressed by noindex</summary>
|
||||
|
||||
When a page has `noindex`, Kirby SEO also removes some related tags that don't make sense on a page hidden from search engines:
|
||||
|
||||
- `<link rel="canonical">` is not rendered
|
||||
- `<meta property="og:url">` is not rendered
|
||||
- `<link rel="alternate" hreflang="...">` tags are not rendered
|
||||
|
||||
Other tags like `<title>`, `<meta name="description">` and Open Graph tags are still rendered.
|
||||
|
||||
</details>
|
||||
35
site/plugins/kirby-seo/docs/1_features/01_sitemap.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
title: Sitemap
|
||||
intro: A sitemap for search engines, generated from your pages
|
||||
---
|
||||
|
||||
Kirby SEO generates an XML sitemap at `https://example.com/sitemap.xml`. Search engines like Google use it to discover all the pages on your site. You don't need to create or update it manually: it updates whenever your content changes.
|
||||
|
||||
What you see here are the defaults. The sitemap generator and all its options can be changed or replaced entirely. See [Customizing the sitemap](2_customization/05_sitemap) for details.
|
||||
|
||||
## What's in the sitemap
|
||||
|
||||
The sitemap only includes pages that are [visible to search engines](1_features/00_robots-indexing). Unlisted pages, drafts, and pages excluded by robots settings are left out. The `error` template is also excluded by default.
|
||||
|
||||
Each page in the sitemap includes:
|
||||
|
||||
- `loc`: the page URL
|
||||
- `lastmod`: when the page was last modified
|
||||
- `changefreq`: how often the page is likely to change (default: `weekly`)
|
||||
- `priority`: how important the page is relative to other pages on your site
|
||||
|
||||
Priority is calculated from page depth: the homepage gets `1.0`, and each level deeper subtracts `0.2`, down to a minimum of `0.2`.
|
||||
|
||||
A `Sitemap:` line is also added to your [robots.txt](1_features/00_robots-indexing) automatically, so crawlers know where to find it.
|
||||
|
||||
## Multilingual sites
|
||||
|
||||
If your Kirby site has multiple languages, the sitemap automatically includes `hreflang` links for each page. These tell search engines which language versions of a page exist, so they can show the right one in search results.
|
||||
|
||||
Only languages where a translation actually exists are included. There is no separate sitemap per language: all translations are listed in a single sitemap using `<xhtml:link>` elements.
|
||||
|
||||
## Browser view
|
||||
|
||||
If you open `https://example.com/sitemap.xml` in a browser, you'll see a styled table instead of raw XML. This is powered by an XSL stylesheet that Kirby SEO serves at `/sitemap.xsl`. On multilingual sites, each URL shows language badges linking to its alternate translations.
|
||||
|
||||
To see the raw XML, use `view-source:https://example.com/sitemap.xml` in your browser's address bar.
|
||||
56
site/plugins/kirby-seo/docs/1_features/02_indexnow.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: IndexNow
|
||||
intro: Notify search engines when your content changes
|
||||
---
|
||||
|
||||
Normally, search engines discover changes to your site on their own schedule, which can take days or weeks. [IndexNow](https://www.indexnow.org/) lets you skip the wait: whenever you save, publish, or move a page in Kirby, Kirby SEO notifies search engines so they can re-crawl right away.
|
||||
|
||||
IndexNow is supported by Bing, Yandex, Seznam, and others. Kirby SEO sends a single request to `api.indexnow.org`, which propagates to all participating search engines. Google does not support IndexNow but is not affected by it.
|
||||
|
||||
## How it works
|
||||
|
||||
IndexNow is triggered on three events:
|
||||
|
||||
- A page is saved
|
||||
- A page changes status (e.g. draft to listed)
|
||||
- A page's slug changes
|
||||
|
||||
Only pages that are listed and not marked as `noindex` are submitted. On local environments (localhost), no requests are sent.
|
||||
|
||||
## API key
|
||||
|
||||
IndexNow requires an API key to verify that you own the domain. Kirby SEO generates one automatically and caches it permanently. Search engines can verify it at `https://example.com/indexnow-{key}.txt`, which Kirby SEO serves as a route. You don't need to manage this yourself.
|
||||
|
||||
## Related URLs
|
||||
|
||||
By default, only the changed page itself is submitted. But when a page changes, other pages might be affected too: a blog post's parent archive shows a different excerpt, or sibling pages have updated navigation.
|
||||
|
||||
You can configure rules to submit related URLs along with the changed page:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'indexnow' => [
|
||||
'rules' => [
|
||||
// when a blog post changes, also re-index its parent
|
||||
'/blog/*' => ['parent' => true],
|
||||
|
||||
// when an article changes, re-index two levels of parents and specific URLs
|
||||
'article' => ['parent' => 2, 'urls' => ['/blog', '/']],
|
||||
|
||||
// when a product changes, re-index siblings and all category pages
|
||||
'product' => ['parent' => true, 'siblings' => true, 'templates' => ['category']],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Rules match either by URL pattern (`/blog/*`) or by template name (`article`). Each rule can use any combination of:
|
||||
|
||||
- `parent`: `true` for the direct parent, or a number for how many levels up
|
||||
- `children`: `true` for all descendants, or a number to limit depth
|
||||
- `siblings`: `true` to include all pages at the same level
|
||||
- `urls`: an array of specific URLs to submit
|
||||
- `templates`: an array of template names, all pages with those templates will be submitted
|
||||
31
site/plugins/kirby-seo/docs/1_features/03_panel-previews.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
title: Panel Previews
|
||||
intro: See how your pages look in search results and social shares
|
||||
---
|
||||
|
||||
Meta titles and descriptions can look different in context than they do in a text field. The SEO tab in the Panel has a live preview sidebar that shows how the current page will appear when shared or found in search results. It updates as you type, so you can catch issues like truncated titles or missing images before you publish.
|
||||
|
||||
There are three preview types:
|
||||
|
||||
- **Google**: a search result card with your meta title, description, URL and favicon
|
||||
- **Facebook**: a social sharing card with OG title, description and image
|
||||
- **Slack**: a link preview card as Slack shows it when someone pastes a URL
|
||||
|
||||
The preview picks up all values through the [Meta Cascade](0_getting-started/1_your-first-meta-tags). If you haven't set an OG title, the preview shows the meta title instead, just like a real crawler would see it.
|
||||
|
||||
Keep in mind that Google sometimes decides to show a different title or description than what you set, if it thinks something else on the page is more relevant to the search query. The preview shows what you _tell_ Google to display, but the actual search result may look different. This is normal and not something we can control.
|
||||
|
||||
On the Site SEO tab, the preview shows data for the homepage since the site itself doesn't have a URL.
|
||||
|
||||
## Choosing which previews to show
|
||||
|
||||
By default, all three previews are available. If you only care about Google and Facebook, you can remove Slack from the list:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'previews' => ['google', 'facebook'],
|
||||
],
|
||||
];
|
||||
```
|
||||
65
site/plugins/kirby-seo/docs/1_features/04_ai-assist.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: AI Assist
|
||||
intro: Let AI draft your meta titles and descriptions
|
||||
---
|
||||
|
||||
Writing meta titles and descriptions for every page gets tedious fast. AI Assist can generate them for you based on the actual content of each page. It reads the page, looks at your existing meta fields, and drafts a title or description that fits.
|
||||
|
||||
AI Assist works for these fields:
|
||||
|
||||
- Meta title
|
||||
- Meta description
|
||||
- Open Graph description
|
||||
- Site-level meta description
|
||||
- Site-level Open Graph description
|
||||
|
||||
The generated text matches the language of your page and respects your title template length, so titles don't get cut off in search results.
|
||||
|
||||
## Setting up a provider
|
||||
|
||||
AI Assist needs an API key from an AI provider. Sign up with one of the supported providers and create an API key in their dashboard. Kirby SEO supports [OpenAI](https://platform.openai.com/), [Anthropic](https://console.anthropic.com/), [Google Gemini](https://ai.google.dev/), and [OpenRouter](https://openrouter.ai/) out of the box. OpenRouter is a good starting point because it gives you access to many models through a single API, including models with free tiers.
|
||||
|
||||
AI providers charge based on usage. These costs are separate from your Kirby SEO license. For generating short texts like meta titles and descriptions, costs are typically very low.
|
||||
|
||||
Here's an example using OpenRouter:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'ai' => [
|
||||
'provider' => 'openrouter',
|
||||
'providers' => [
|
||||
'openrouter' => [
|
||||
'config' => [
|
||||
'apiKey' => 'sk-or-...',
|
||||
'model' => 'google/gemini-3-flash-preview',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
For generating meta titles and descriptions, you don't need the most powerful model. Small, fast models work well and keep costs low. Our recommendation is **Google Gemini 3 Flash** via the built-in Gemini provider: it's fast, capable, and has a generous free tier.
|
||||
|
||||
You can change the model for any provider via the `model` key in the config, as shown in the example above.
|
||||
|
||||
For config options for all providers, see [Customizing AI Assist](2_customization/06_ai-assist) for details.
|
||||
|
||||
## Using AI Assist in the Panel
|
||||
|
||||
The provider config is a one-time setup by the developer. Once it's in place, editors just use the buttons in the Panel.
|
||||
|
||||
You'll see new buttons next to the meta title and description fields in the SEO tab.
|
||||
|
||||
The **Generate** button drafts a new value from scratch based on the page content. If the field already has a value, it changes to **Regenerate**. If you want more control, click **Customize** to add your own instructions before generating, like "keep it under 50 characters" or "focus on the pricing".
|
||||
|
||||
Already have a value but want to tweak it? The **Edit** button lets you revise the current text with instructions like "make it shorter" or "add the brand name".
|
||||
|
||||
The result appears word by word. You can stop it early if you want.
|
||||
|
||||
## Custom providers and prompts
|
||||
|
||||
You can add your own providers or override the built-in prompts. See [Customizing AI Assist](2_customization/06_ai-assist) for details.
|
||||
57
site/plugins/kirby-seo/docs/1_features/05_alt-texts.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
title: Alt Text Field
|
||||
intro: Structured alt text for images, with AI generation and a decorative toggle
|
||||
---
|
||||
|
||||
Every image on the web needs an `alt` attribute. Images that convey meaning need descriptive text. Decorative images need an empty `alt=""`, which tells screen readers to skip them entirely. Getting this wrong hurts accessibility.
|
||||
|
||||
Kirby SEO provides a dedicated `alt-text` field that handles both cases. It stores structured data instead of a plain string, so your templates always render the correct HTML attributes.
|
||||
|
||||
## Adding the field
|
||||
|
||||
Add a `alt-text` field to any file blueprint:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/files/image.yml
|
||||
fields:
|
||||
alt:
|
||||
type: alt-text
|
||||
label: Alt Text
|
||||
```
|
||||
|
||||
Editors see a text input with a toggle. The toggle marks an image as decorative: when active, the text input disappears because decorative images don't need a description.
|
||||
|
||||
## AI generation
|
||||
|
||||
If [AI Assist](1_features/04_ai-assist) is configured, the field shows **Generate** and **Customize** buttons. The AI sees the actual image and writes alt text based on it, the filename, and the page context. Results stream in word by word and can be stopped early.
|
||||
|
||||
You can disable AI for a specific field by setting `ai: false` in the blueprint.
|
||||
|
||||
### Auto-generation on upload
|
||||
|
||||
Set `autogenerate: true` to generate alt text automatically when an image is uploaded:
|
||||
|
||||
```yaml
|
||||
alt:
|
||||
type: alt-text
|
||||
autogenerate: true
|
||||
```
|
||||
|
||||
By default, this runs synchronously during the upload. For better performance, you can offload it to a background queue. See [Background Processing](2_customization/10_background-processing) for setup. On multilingual sites, a single AI call generates alt text for all languages at once.
|
||||
|
||||
## Using alt text in templates
|
||||
|
||||
The plugin registers a `toAltText()` field method that returns an `AltText` object. Use its `toAttr()` method to get the correct HTML attributes, then spread them into your image helper:
|
||||
|
||||
```php
|
||||
<?= Html::img($file->url(), [
|
||||
'width' => $file->width(),
|
||||
'height' => $file->height(),
|
||||
...$file->alt()->toAltText()->toAttr(),
|
||||
]) ?>
|
||||
// <img alt="A dog playing fetch" src="..." width="..." height="...">
|
||||
|
||||
// decorative image:
|
||||
// <img alt="" src="..." width="..." height="...">
|
||||
|
||||
The field also works with plain string values from existing `alt` fields. If you migrate from a regular text field, `toAltText()` treats the old value as manual alt text.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
title: Google Search Console
|
||||
intro: See what people search for when they find your pages
|
||||
---
|
||||
|
||||
Kirby SEO can pull data from [Google Search Console](https://search.google.com/search-console) directly into the Panel. Editors can see search performance right next to their content, without needing their own Google account or leaving the Panel. You see which search queries lead people to each page, how many clicks and impressions you get, your click-through rate, and your average position in search results.
|
||||
|
||||

|
||||
|
||||
The data shows up in a section on both the Site and individual page views. On a page, the queries are filtered to that specific page. On the Site view, you see all queries across your entire site. The section shows the top 10 search queries, sorted by clicks. You can switch the sorting to impressions, CTR, or position.
|
||||
|
||||
Click **Show all** to open a full table with all queries and all four metrics at once. There's also a direct link to open the page in Google Search Console if you want to dig deeper.
|
||||
|
||||
Data is cached for 24 hours, so it won't hit Google's API on every page load.
|
||||
|
||||
## Connecting your Google account
|
||||
|
||||
**IMPORTANT:** The following section describes a feature that is not implemented yet. For now, the Search Console integration requires your own GSC credentials.
|
||||
|
||||
The Google Search Console section needs access to your Google account. To keep setup simple, API requests to Google are proxied through a server operated by Love & Kindness GmbH (the company behind Kirby SEO). This proxy mode requires an active Kirby SEO license, which is used only for rate limiting. We do not log the content of any requests or responses, so we cannot see the actual search data for your site. The source code for the proxy is [open source on GitHub](https://github.com/tobimori/kirby-seo-gsc-proxy).
|
||||
|
||||
1. Open the Panel and navigate to any page with the SEO tab.
|
||||
2. In the Google Search Console section, click **Connect**.
|
||||
3. Google asks you to sign in and grant read-only access to your Search Console data.
|
||||
4. Back in the Panel, select which Search Console property to use. If your domain is already registered in Google Search Console, it will be pre-selected.
|
||||
|
||||
The section starts showing data once the property is selected.
|
||||
|
||||
If you'd rather not use the proxy, you can connect with your own Google OAuth credentials instead. See [Setting up your own GSC credentials](2_customization/07_gsc-setup) for details.
|
||||
6
site/plugins/kirby-seo/docs/1_features/07_seo-audit.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: SEO Audit
|
||||
intro:
|
||||
---
|
||||
|
||||
Coming soon
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: SEO Overview
|
||||
intro:
|
||||
---
|
||||
|
||||
Coming soon
|
||||
42
site/plugins/kirby-seo/docs/1_features/09_utm-share.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
title: UTM Share
|
||||
intro: Share links with tracking parameters for your marketing campaigns
|
||||
---
|
||||
|
||||
When you share a link to your site in a newsletter, a social media post, or an ad, you want to know which links actually bring in traffic. UTM parameters are tags you add to a URL so analytics tools like Google Analytics can tell you exactly where a visitor came from.
|
||||
|
||||
A URL with UTM parameters looks like this:
|
||||
|
||||
```
|
||||
https://example.com/blog/my-post?utm_source=newsletter&utm_medium=email&utm_campaign=spring-sale
|
||||
```
|
||||
|
||||
Kirby SEO adds a **UTM Share** button to your page views. Click it to open a dialog where you can fill in the parameters and copy the resulting URL.
|
||||
|
||||

|
||||
|
||||
The dialog has five standard UTM parameters:
|
||||
|
||||
- `utm_source`: where the traffic comes from (e.g. `google`, `newsletter`)
|
||||
- `utm_medium`: the type of channel (e.g. `cpc`, `email`, `social`)
|
||||
- `utm_campaign`: the name of the campaign (e.g. `spring_sale`)
|
||||
- `utm_content`: to tell apart different links in the same campaign (e.g. `logo_link`)
|
||||
- `utm_term`: the keyword, for paid search ads (e.g. `running shoes`)
|
||||
|
||||
You don't need all five. Most of the time, `utm_source`, `utm_medium`, and `utm_campaign` are enough.
|
||||
|
||||
There's also a `ref` field. This is not part of the UTM standard, but many analytics tools (like Plausible and Pirsch) use it as a lightweight way to track the referring site.
|
||||
|
||||
To add the button to your page blueprints:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/pages/default.yml
|
||||
buttons:
|
||||
- open
|
||||
- preview
|
||||
- "-"
|
||||
- settings
|
||||
- languages
|
||||
- status
|
||||
- utm-share
|
||||
```
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
title: Heading Structure
|
||||
intro: Check your heading hierarchy while editing
|
||||
---
|
||||
|
||||
Search engines and screen readers rely on headings (H1, H2, H3, ...) to understand the structure of a page. A well-structured page starts with a single H1 and uses the other levels in order, without skipping any.
|
||||
|
||||
When headings skip levels (e.g. H2 followed by H4) or when there are multiple H1s, search engines have a harder time figuring out what the page is about. Screen readers use the heading tree to let users jump between sections, so broken hierarchy also affects accessibility.
|
||||
|
||||
Most Kirby sites tie heading levels to visual styles: H1 is the largest text, H2 is smaller, and so on. Editors often pick a heading level based on how big they want the text to look, not based on what it means semantically. An H3 after an H1 might look fine on the page, but it tells search engines and screen readers that something is missing.
|
||||
|
||||
Kirby SEO has a Panel section that extracts all headings from the current page and displays them as a nested tree. You see the full hierarchy at a glance, and headings that break the structure are highlighted. The section updates as the page content changes, so editors can fix issues while they write.
|
||||
|
||||
## Adding the section to your blueprint
|
||||
|
||||
Place the section next to your content editor, for example in a sidebar column beside your blocks or layout field:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/pages/default.yml
|
||||
tabs:
|
||||
content:
|
||||
columns:
|
||||
- width: 2/3
|
||||
fields:
|
||||
blocks:
|
||||
type: blocks
|
||||
- width: 1/3
|
||||
sections:
|
||||
headingStructure:
|
||||
type: heading-structure
|
||||
```
|
||||
BIN
site/plugins/kirby-seo/docs/1_features/gsc-section.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
site/plugins/kirby-seo/docs/1_features/robots-indicator.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
site/plugins/kirby-seo/docs/1_features/robots-section.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
site/plugins/kirby-seo/docs/1_features/utm-share.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
title: Programmatic Content
|
||||
intro: Set default SEO values from page models
|
||||
---
|
||||
|
||||
Sometimes you want SEO fields to default to values from other fields, or generate them from code. A common example is using a plugin like [kirby-paparazzi](https://github.com/tobimori/kirby-paparazzi) to generate OG images for every page.
|
||||
|
||||
Add a `metaDefaults` method to a [page model](https://getkirby.com/docs/guide/templates/page-models). It returns an array of meta tag names mapped to their values. These defaults apply through the [Meta Cascade](0_getting-started/1_your-first-meta-tags) when no editor override exists.
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/models/article.php
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
|
||||
class ArticlePage extends Page
|
||||
{
|
||||
public function metaDefaults(string $lang = null): array
|
||||
{
|
||||
return [
|
||||
'og:image' => "{$this->url()}.png",
|
||||
'og:image:width' => 1230,
|
||||
'og:image:height' => 600,
|
||||
'description' => $this->content($lang)->summary()->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Kirby SEO picks the correct tag syntax from the name. Open Graph keys (starting with `og:`) get `property` and `content` attributes, link keys like `canonical` get `rel` and `href`, and everything else gets `name` and `content`.
|
||||
|
||||
## Custom tag attributes
|
||||
|
||||
If you need full control over a tag's output, pass an array with `tag` and `attributes`:
|
||||
|
||||
```php
|
||||
return [
|
||||
// shorthand
|
||||
'description' => 'A page about something',
|
||||
|
||||
// tag with inner content
|
||||
[
|
||||
'tag' => 'title',
|
||||
'content' => 'My Page Title',
|
||||
],
|
||||
|
||||
// tag with attributes
|
||||
[
|
||||
'tag' => 'meta',
|
||||
'attributes' => [
|
||||
'property' => 'og:image:alt',
|
||||
'content' => "An image of {$this->title()}",
|
||||
],
|
||||
],
|
||||
|
||||
// link tag
|
||||
[
|
||||
'tag' => 'link',
|
||||
'attributes' => [
|
||||
'rel' => 'preconnect',
|
||||
'href' => 'https://fonts.googleapis.com',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Global defaults via a plugin
|
||||
|
||||
Page models only apply to pages with a specific template. If you want to add meta tags to all pages, you can register a `metaDefaults` [page method](https://getkirby.com/docs/reference/plugins/extensions/page-methods) in a plugin:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/plugins/my-meta/index.php
|
||||
|
||||
Kirby::plugin('my/meta', [
|
||||
'pageMethods' => [
|
||||
'metaDefaults' => function (string $lang = null): array {
|
||||
return [
|
||||
'og:image' => "{$this->url()}.png",
|
||||
];
|
||||
},
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
title: Meta Cascade
|
||||
intro: Understand how meta values are resolved across multiple levels
|
||||
---
|
||||
|
||||
Kirby SEO is built with a cascading approach. Meta tags can be defined on multiple levels, and they are merged based on priority. If a value is empty on one level, it falls through to the next. This is how the plugin forms the final metadata for every page.
|
||||
|
||||
The default cascade, in order of priority:
|
||||
|
||||
1. **Page fields** (`fields`) -- Values the editor enters in the page's SEO blueprint fields. This is the highest priority: if an editor sets a meta description, it always wins.
|
||||
|
||||
2. **Programmatic** (`programmatic`) -- Values returned by `metaDefaults()` in [page models](2_customization/00_programmatic-content). Use this for computed defaults like generated OG images or descriptions derived from other fields.
|
||||
|
||||
3. **Parent** (`parent`) -- Inherited values from the parent page. If a parent page has "inherit settings" enabled for a field, its children pick up those values. Useful for giving all blog posts the same title template, for example.
|
||||
|
||||
4. **Fallback fields** (`fallbackFields`) -- Falls back to meta field values for Open Graph tags. If no `ogDescription` is set, the page's `metaDescription` is used instead.
|
||||
|
||||
5. **Site** (`site`) -- Global values from the site's SEO blueprint fields. These apply to all pages that don't have their own value set at a higher level.
|
||||
|
||||
6. **Options** (`options`) -- The final fallback, defined in the plugin's config defaults. These are the built-in defaults like the title template `{{ title }} - {{ site.title }}`.
|
||||
|
||||
## Configuring the cascade
|
||||
|
||||
The cascade order is configurable in your `config.php`. You can remove levels, reorder them, or add optional ones:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'cascade' => [
|
||||
'fields',
|
||||
'programmatic',
|
||||
'parent',
|
||||
'fallbackFields',
|
||||
'site',
|
||||
'options',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Remove an entry to skip that level entirely. For example, to disable parent inheritance:
|
||||
|
||||
```php
|
||||
'cascade' => [
|
||||
'fields',
|
||||
'programmatic',
|
||||
'fallbackFields',
|
||||
'site',
|
||||
'options',
|
||||
],
|
||||
```
|
||||
|
||||
## Restore the 1.x behavior
|
||||
|
||||
In 1.x, if you set an `ogDescription` at the site level, it applied to every page, even pages that had their own `metaDescription`. The page-specific description never made it into the Open Graph tags.
|
||||
|
||||
In 2.x, the `fallbackFields` level sits between `parent` and `site`, so a page's `metaDescription` is used as `ogDescription` before site-wide Open Graph values are reached.
|
||||
|
||||
To restore the 1.x behavior, remove `fallbackFields` from the cascade:
|
||||
|
||||
```php
|
||||
'cascade' => [
|
||||
'fields',
|
||||
'programmatic',
|
||||
'parent',
|
||||
'site',
|
||||
'options',
|
||||
],
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>If you used <code>fallbackFields</code> with additional mappings in 1.x</summary>
|
||||
|
||||
In 1.x, `fallbackFields` also mapped `ogTemplate` to `metaTemplate`. If you relied on this, you can restore it by extending the `Meta` class and overriding the `FALLBACK_MAP` constant:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use tobimori\Seo\Meta;
|
||||
|
||||
class MyMeta extends Meta
|
||||
{
|
||||
public const FALLBACK_MAP = [
|
||||
'ogDescription' => 'metaDescription',
|
||||
'ogTemplate' => 'metaTemplate',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Then register your class in the config. See [Extending the Plugin](2_customization/11_plugin-extensions) for details.
|
||||
|
||||
</details>
|
||||
66
site/plugins/kirby-seo/docs/2_customization/02_robots-txt.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
title: Customizing robots.txt
|
||||
intro: Add custom rules to your robots.txt
|
||||
---
|
||||
|
||||
By default, Kirby SEO generates a simple `robots.txt` that allows all crawlers and blocks the Panel. If you need to add your own rules, use the `robots.content` option.
|
||||
|
||||
## Blocking specific bots
|
||||
|
||||
Some AI providers crawl websites to use the content as training data. You can block their crawlers:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'robots' => [
|
||||
'content' => [
|
||||
'GPTBot' => [
|
||||
'Disallow' => ['/'],
|
||||
],
|
||||
'Google-Extended' => [
|
||||
'Disallow' => ['/'],
|
||||
],
|
||||
'CCBot' => [
|
||||
'Disallow' => ['/'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
This adds rules for each bot while keeping the default rules for all other crawlers intact.
|
||||
|
||||
## Custom rules for all crawlers
|
||||
|
||||
If you set rules for `*`, they replace the default rules entirely:
|
||||
|
||||
```php
|
||||
'content' => [
|
||||
'*' => [
|
||||
'Allow' => ['/'],
|
||||
'Disallow' => ['/panel', '/content', '/private'],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Mixing rules
|
||||
|
||||
You can combine rules for all crawlers with rules for specific bots:
|
||||
|
||||
```php
|
||||
'content' => [
|
||||
'*' => [
|
||||
'Allow' => ['/'],
|
||||
'Disallow' => ['/panel', '/content'],
|
||||
],
|
||||
'GPTBot' => [
|
||||
'Disallow' => ['/'],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
The `Sitemap:` line is added automatically if the [sitemap module](1_features/01_sitemap) is active. You can override it with the `robots.sitemap` option.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
title: Opting Out of AI Training
|
||||
intro: Signal to AI crawlers that your content should not be used for training
|
||||
---
|
||||
|
||||
The `noai` and `noimageai` robot directives tell AI crawlers not to use your content or images for training. These are not an official standard, but were introduced by [DeviantArt and Spawning](https://www.deviantart.com/team/journal/UPDATE-All-Deviations-Are-Opted-Out-of-AI-Datasets-934500371) and are respected by some AI providers. Like all robot directives, they are signals, not hard blocks.
|
||||
|
||||
Kirby SEO has a `types` option that controls which robot directives are available. Add `ai` and `imageai` to the list:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'robots' => [
|
||||
'types' => ['index', 'follow', 'archive', 'imageindex', 'snippet', 'ai', 'imageai'],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
The new fields show up in the robots section of the SEO tab. If you previously disabled `robots.pageSettings`, you need to re-enable it for the fields to appear.
|
||||
|
||||
By default, all directives are set to "Yes" (allowed). To opt out of AI training, an editor needs to set the AI Training and AI Image Training fields to "No". The plugin then outputs `noai` and `noimageai` in the robots meta tag.
|
||||
|
||||
If you want to opt out for all pages at once, set it on the Site level instead of per page. Translations for the field labels are included in the plugin.
|
||||
77
site/plugins/kirby-seo/docs/2_customization/05_sitemap.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
title: Customizing the Sitemap
|
||||
intro: Fine-tune the built-in sitemap or replace it entirely
|
||||
---
|
||||
|
||||
The built-in sitemap generator has a few options to adjust its behavior. For most sites, these are enough. If you need full control, you can replace the generator with your own.
|
||||
|
||||
## Excluding templates
|
||||
|
||||
By default, only the `error` template is excluded. To exclude more templates:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'sitemap' => [
|
||||
'excludeTemplates' => ['error', 'redirect', 'internal'],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Grouping by template
|
||||
|
||||
By default, all pages end up in a single sitemap. If you have many pages, you can split them into separate sitemaps per template. This creates a sitemap index at `/sitemap.xml` with links to `/sitemap-blog.xml`, `/sitemap-product.xml`, etc.
|
||||
|
||||
```php
|
||||
'sitemap' => [
|
||||
'groupByTemplate' => true,
|
||||
],
|
||||
```
|
||||
|
||||
## Change frequency and priority
|
||||
|
||||
Both `changefreq` and `priority` accept a static value or a callable:
|
||||
|
||||
```php
|
||||
'sitemap' => [
|
||||
'changefreq' => 'daily',
|
||||
'priority' => fn (Page $page) => $page->isHomePage() ? 1.0 : 0.5,
|
||||
],
|
||||
```
|
||||
|
||||
The default `changefreq` is `weekly`. The default `priority` is calculated from page depth: the homepage gets `1.0`, each level deeper subtracts `0.2`, down to `0.2`.
|
||||
|
||||
## Writing your own generator
|
||||
|
||||
If the options above aren't enough, you can replace the entire sitemap generator. The `generator` option takes a callable that receives a `SitemapIndex` instance. Here's a minimal example:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'sitemap' => [
|
||||
'generator' => function (SitemapIndex $sitemap) {
|
||||
$index = $sitemap->create('pages');
|
||||
|
||||
foreach (site()->index()->listed() as $page) {
|
||||
$index->createUrl($page->url())
|
||||
->lastmod($page->modified())
|
||||
->changefreq('weekly')
|
||||
->priority(0.8);
|
||||
}
|
||||
},
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
`$sitemap->create('key')` creates a sitemap group. `$index->createUrl($url)` adds a URL entry, and you can chain `->lastmod()`, `->changefreq()`, `->priority()`, and `->alternates()` on it.
|
||||
|
||||
The built-in generator does more: it filters by robots settings, respects `excludeTemplates`, handles `groupByTemplate`, and adds hreflang links for multilingual sites. You can find its source in `config/options/sitemap.php` as a reference for your own.
|
||||
134
site/plugins/kirby-seo/docs/2_customization/06_ai-assist.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
title: Customizing AI Assist
|
||||
intro: Override prompts or add your own AI provider
|
||||
---
|
||||
|
||||
## Overriding prompts
|
||||
|
||||
AI Assist uses Kirby snippets for its prompts. You can override any of them by creating a snippet with the same path in your project.
|
||||
|
||||
The built-in prompt snippets are:
|
||||
|
||||
- `seo/prompts/tasks/title` - Meta title generation
|
||||
- `seo/prompts/tasks/description` - Meta description generation
|
||||
- `seo/prompts/tasks/og-description` - Open Graph description generation
|
||||
- `seo/prompts/tasks/site-description` - Site-level meta description
|
||||
- `seo/prompts/tasks/og-site-description` - Site-level OG description
|
||||
|
||||
To override the meta title prompt, create `site/snippets/seo/prompts/tasks/title.php` in your project. Kirby's snippet loading will pick up your version instead of the built-in one.
|
||||
|
||||
Each prompt snippet receives these variables:
|
||||
|
||||
- `$page` - the current page
|
||||
- `$site` - the site object
|
||||
- `$instructions` - custom instructions from the editor (if any)
|
||||
- `$edit` - the existing text when editing (if any)
|
||||
|
||||
There are also shared snippets that the task prompts include:
|
||||
|
||||
- `seo/prompts/introduction` - Defines the AI's role and rules
|
||||
- `seo/prompts/content` - Extracts the page content
|
||||
- `seo/prompts/meta` - Shows existing metadata for context
|
||||
|
||||
You can override these too. Look at the built-in prompts in `site/plugins/kirby-seo/snippets/prompts/` to understand their structure before writing your own.
|
||||
|
||||
## Adding a custom provider
|
||||
|
||||
If you need a provider that isn't built in, you can add your own. A provider has two parts: a driver class that handles the API communication, and a config entry that registers it.
|
||||
|
||||
Create a class that extends `tobimori\Seo\Ai\Driver`. The only method you need to implement is `stream`, which receives a prompt string and must yield `Chunk` objects as the response comes in.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Ai;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class MyProvider extends Driver
|
||||
{
|
||||
public function stream(string $prompt, string|null $model = null): Generator
|
||||
{
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$model = $model ?? $this->config('model', 'default-model');
|
||||
$endpoint = $this->config('endpoint', required: true);
|
||||
|
||||
$stream = new SseStream($endpoint, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"Authorization: Bearer {$apiKey}",
|
||||
], [
|
||||
'model' => $model,
|
||||
'input' => $prompt,
|
||||
'stream' => true,
|
||||
], (int)$this->config('timeout', 120));
|
||||
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
if ($type === 'start') {
|
||||
yield Chunk::streamStart($event);
|
||||
}
|
||||
|
||||
if ($type === 'delta') {
|
||||
yield Chunk::textDelta($event['text'] ?? '', $event);
|
||||
}
|
||||
|
||||
if ($type === 'done') {
|
||||
yield Chunk::streamEnd($event);
|
||||
}
|
||||
|
||||
if ($type === 'error') {
|
||||
yield Chunk::error($event['message'] ?? 'Unknown error', $event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The driver uses `$this->config()` to read values from the provider's `config` array in `config.php`. Pass `required: true` to throw an error if a value is missing.
|
||||
|
||||
`SseStream` is a helper class included in Kirby SEO that handles the cURL request and SSE parsing. You pass it the endpoint, headers, payload, and a mapper function that converts raw SSE events into `Chunk` objects.
|
||||
|
||||
If your API doesn't use SSE, you can skip `SseStream` and yield chunks directly.
|
||||
|
||||
The chunks the Panel expects, in order:
|
||||
|
||||
1. `Chunk::streamStart()` - Signals the stream has started
|
||||
2. `Chunk::textDelta($text)` - Each piece of generated text (repeated)
|
||||
3. `Chunk::textComplete()` - The text is done
|
||||
4. `Chunk::streamEnd()` - The stream is finished
|
||||
|
||||
If something goes wrong, yield `Chunk::error($message)` at any point.
|
||||
|
||||
## Registering the provider
|
||||
|
||||
Add your driver to the config and set it as the active provider:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'ai' => [
|
||||
'provider' => 'myprovider',
|
||||
'providers' => [
|
||||
'myprovider' => [
|
||||
'driver' => \App\Ai\MyProvider::class,
|
||||
'config' => [
|
||||
'apiKey' => 'sk-...',
|
||||
'model' => 'my-model',
|
||||
'endpoint' => 'https://api.example.com/v1/chat',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
See the built-in drivers in `site/plugins/kirby-seo/classes/Ai/Drivers/` for complete implementations.
|
||||
45
site/plugins/kirby-seo/docs/2_customization/07_gsc-setup.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
title: Setting up Google Search Console
|
||||
intro: Connect Search Console with your own Google OAuth credentials
|
||||
---
|
||||
|
||||
By default, the Search Console integration uses a proxy to keep setup simple. If you'd rather connect directly, you can set up your own Google OAuth credentials instead. This requires a Google Cloud project with the Search Console API enabled. The API is free to use.
|
||||
|
||||
## Create OAuth credentials
|
||||
|
||||
Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project, or use an existing one.
|
||||
|
||||
Navigate to **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth client ID** and configure it:
|
||||
|
||||
- **Application type:** Web application
|
||||
- **Name:** e.g. "Kirby SEO on example.com"
|
||||
- **Authorized redirect URIs:** your site URL followed by `/__seo/gsc/callback`, e.g. `https://example.com/__seo/gsc/callback`
|
||||
|
||||
Download the JSON file when prompted. You'll need it in the next step.
|
||||
|
||||
Then go to **APIs & Services** → **Library**, search for "Google Search Console API" and enable it. Without this, the OAuth flow will succeed but the API requests will fail.
|
||||
|
||||
## Add credentials to your config
|
||||
|
||||
Place the downloaded JSON file in your `site/config` directory (e.g. `site/config/gsc-credentials.json`), then reference it in your config:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
use Kirby\Data\Json;
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'searchConsole' => [
|
||||
'credentials' => Json::read(__DIR__ . '/gsc-credentials.json'),
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Connect in the Panel
|
||||
|
||||
Open the Panel and navigate to any page with the SEO tab. The Google Search Console section now shows a **Connect** button. Click it and authorize with your Google account. Make sure the Google account you use has access to the Search Console property for your site.
|
||||
|
||||
After authorizing, select which Search Console property to use. The section starts showing data once the property is selected.
|
||||
89
site/plugins/kirby-seo/docs/2_customization/08_schema-org.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: Schema.org (JSON-LD)
|
||||
intro: Add structured data to your pages
|
||||
---
|
||||
|
||||
Kirby SEO can output Schema.org structured data as JSON-LD. It uses the [spatie/schema-org](https://github.com/spatie/schema-org) package, which must be installed separately:
|
||||
|
||||
```bash
|
||||
composer require spatie/schema-org
|
||||
```
|
||||
|
||||
Once installed, a `WebSite` schema is generated automatically for every page with the page's title, description, and canonical URL. You can build on top of this or add your own schemas.
|
||||
|
||||
## Adding structured data
|
||||
|
||||
The plugin exposes a global store for Schema.org objects. You can access it from templates, snippets, or block snippets using `$page->schema()` and `$site->schema()`. Calling the same type twice returns the same instance, so you can build up a schema across different files.
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/templates/article.php
|
||||
|
||||
$page->schema('Article')
|
||||
->headline($page->title()->value())
|
||||
->datePublished($page->date()->toDate('c'))
|
||||
->author(
|
||||
schema('Person')
|
||||
->name($page->author()->value())
|
||||
);
|
||||
```
|
||||
|
||||
`$page->schema($type)` returns the stored schema for that type, or creates a new one if it doesn't exist yet. Both also exist as `$site->schema()` and `$site->schemas()` for site-level schemas.
|
||||
|
||||
The global `schema($type)` function creates a new instance without storing it. Use it for nested objects like the `Person` above that don't need their own top-level entry.
|
||||
|
||||
## Building schemas across blocks
|
||||
|
||||
Because `$page->schema()` always returns the same instance, you can add to a schema from individual block snippets. This is useful for types like `FAQPage` where the content comes from multiple blocks:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/snippets/blocks/faq.php
|
||||
|
||||
$page->schema('FAQPage')
|
||||
->mainEntity([
|
||||
...($page->schema('FAQPage')->getProperty('mainEntity') ?? []),
|
||||
schema('Question')
|
||||
->name($block->question())
|
||||
->acceptedAnswer(
|
||||
schema('Answer')->text($block->answer())
|
||||
),
|
||||
]);
|
||||
```
|
||||
|
||||
Each block appends its question to the `mainEntity` array. The final output combines all of them:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How does it work?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "It works like this."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can it handle multiple blocks?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes, it can."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Disabling the default schema
|
||||
|
||||
If you don't want the automatic `WebSite` schema, disable it in your config:
|
||||
|
||||
```php
|
||||
'tobimori.seo' => [
|
||||
'generateSchema' => false,
|
||||
],
|
||||
```
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: Optimizing Head Order
|
||||
intro: Place high-priority elements before stylesheets and scripts
|
||||
---
|
||||
|
||||
The order of elements in the `<head>` can affect perceived page performance. Ideally, the `<title>` element should appear early, before stylesheets and scripts, while other meta tags like Open Graph and description can go last. See [capo.js](https://rviscomi.github.io/capo.js/) for background on why this matters.
|
||||
|
||||
By default, `seo/head` outputs all tags in one block. If you want to split priority tags from the rest, use Kirby's [snippet slots](https://getkirby.com/docs/guide/templates/snippets#passing-data-to-snippets__slots):
|
||||
|
||||
```php
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<?php snippet('seo/head', slots: true) ?>
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<script src="/assets/js/app.js" defer></script>
|
||||
<?php endsnippet() ?>
|
||||
</head>
|
||||
```
|
||||
|
||||
This outputs the `<title>` first, then your stylesheets and scripts from the slot, then the remaining meta tags (description, Open Graph, robots, etc.).
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Setup Background Processing
|
||||
intro:
|
||||
---
|
||||
|
||||
Coming soon
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
title: Extending the Plugin
|
||||
intro: Replace built-in classes with your own
|
||||
---
|
||||
|
||||
Kirby SEO uses a component system similar to [Kirby's own](https://getkirby.com/docs/reference/plugins/components). Every major class in the plugin can be swapped out for a custom one. This lets you change how the plugin works without forking it.
|
||||
|
||||
The built-in components are:
|
||||
|
||||
| Key | Default class | Handles |
|
||||
| ---------- | ---------------------------------- | --------------------------------- |
|
||||
| `meta` | `tobimori\Seo\Meta` | Meta tag generation and cascading |
|
||||
| `ai` | `tobimori\Seo\Ai` | AI Assist provider management |
|
||||
| `indexnow` | `tobimori\Seo\IndexNow` | IndexNow ping requests |
|
||||
| `schema` | `tobimori\Seo\SchemaSingleton` | Schema.org structured data store |
|
||||
| `gsc` | `tobimori\Seo\GoogleSearchConsole` | Google Search Console integration |
|
||||
|
||||
To replace a component, create a class that extends the original. For example, to customize meta tag output, extend the `Meta` class:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/plugins/my-seo/index.php
|
||||
|
||||
use tobimori\Seo\Meta;
|
||||
|
||||
class MyMeta extends Meta
|
||||
{
|
||||
// override any method you need
|
||||
}
|
||||
```
|
||||
|
||||
Then register your class in the config:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'components' => [
|
||||
'meta' => MyMeta::class,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
The rest of the plugin picks up your class automatically. Page methods, hooks, routes, and sections all resolve components through the config, so your class is used everywhere the original would have been. Look at the built-in classes in `site/plugins/kirby-seo/classes/` to see what methods are available to override.
|
||||
157
site/plugins/kirby-seo/docs/3_reference/0_options.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
title: Options
|
||||
intro: All configuration options
|
||||
---
|
||||
|
||||
All options are set under `tobimori.seo` in your `config.php`. Dots in the option names represent nested arrays. For example, `robots.enabled` becomes:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// site/config/config.php
|
||||
|
||||
return [
|
||||
'tobimori.seo' => [
|
||||
'robots' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
You can also use Kirby's flat dot syntax:
|
||||
|
||||
```php
|
||||
return [
|
||||
'tobimori.seo.robots.enabled' => true,
|
||||
];
|
||||
```
|
||||
|
||||
Both are equivalent, but you cannot use dot syntax inside a nested array. `'robots.enabled' => true` only works at the top level as `'tobimori.seo.robots.enabled'`. Inside the `'tobimori.seo'` array, you must use nested arrays.
|
||||
|
||||
## General
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `locale` | `'en_US'` | Default locale for single-language sites |
|
||||
| `dateFormat` | `null` | Custom date format for dates in meta tags |
|
||||
| `generateSchema` | `true` | Generate a default `WebSite` schema for every page. Requires [spatie/schema-org](2_customization/08_schema-org) |
|
||||
| `previews` | `['google', 'facebook', 'slack']` | Which preview types to show in the Panel |
|
||||
| `cascade` | `['fields', 'programmatic', 'parent', 'fallbackFields', 'site', 'options']` | The [meta cascade](2_customization/01_meta-cascade) order |
|
||||
| `canonical.base` | `null` | Base URL for canonical links. Uses the site URL if not set |
|
||||
| `canonical.trailingSlash` | `false` | Add trailing slashes to canonical URLs |
|
||||
| `files.parent` | `null` | Default parent page for file uploads in SEO fields |
|
||||
| `files.template` | `null` | Default file template for SEO file uploads |
|
||||
| `socialMedia` | See below | Social media account fields shown in the site blueprint |
|
||||
|
||||
The `socialMedia` option defines which fields appear in the site blueprint. Default fields: `twitter`, `facebook`, `instagram`, `youtube`, `linkedin`, `bluesky`, `mastodon`. Each key maps to a placeholder URL. Override the array to add or remove fields.
|
||||
|
||||
## Meta defaults
|
||||
|
||||
These are the fallback values for the last level of the [meta cascade](2_customization/01_meta-cascade). They apply when no other level provides a value. Each option can be a static value or a callable that receives the `Page` object.
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ---------------------------------- | ---------------------------------------------------------- |
|
||||
| `default.metaTitle` | Page title | Meta title |
|
||||
| `default.metaTemplate` | `'{{ title }} - {{ site.title }}'` | Title template applied to all pages |
|
||||
| `default.ogTemplate` | `'{{ title }}'` | Open Graph title template |
|
||||
| `default.ogSiteName` | Site title | Open Graph site name |
|
||||
| `default.ogType` | `'website'` | Open Graph type |
|
||||
| `default.ogDescription` | Meta description | Open Graph description, falls back to the meta description |
|
||||
| `default.cropOgImage` | `true` | Crop OG images to 1200x630 |
|
||||
| `default.locale` | Language locale or `'en_US'` | Locale for meta tags |
|
||||
| `default.robotsIndex` | `true` if listed and not debug | Whether pages are indexable |
|
||||
| `default.robotsFollow` | Same as `robotsIndex` | Whether links are followed |
|
||||
| `default.robotsArchive` | Same as `robotsIndex` | Whether archiving is allowed |
|
||||
| `default.robotsImageindex` | Same as `robotsIndex` | Whether image indexing is allowed |
|
||||
| `default.robotsSnippet` | Same as `robotsIndex` | Whether snippets are allowed |
|
||||
|
||||
## Robots
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `robots.enabled` | `true` | Whether the plugin handles robots meta tags and robots.txt |
|
||||
| `robots.index` | `true` (unless debug mode) | Site-wide indexing default. Set to `false` to noindex the entire site |
|
||||
| `robots.followPageStatus` | `true` | Unlisted pages are noindex by default |
|
||||
| `robots.pageSettings` | `true` | Show robots settings on each page in the Panel |
|
||||
| `robots.types` | `['index', 'follow', 'archive', 'imageindex', 'snippet']` | Available robot directive types. Add `'ai'` and `'imageai'` for [AI training controls](2_customization/03_robots-noai) |
|
||||
| `robots.content` | `[]` | Custom [robots.txt rules](2_customization/02_robots-txt) per user agent |
|
||||
| `robots.sitemap` | `null` | Custom sitemap URL for robots.txt. Auto-detected when the sitemap module is active |
|
||||
|
||||
## Sitemap
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `sitemap.enabled` | `true` | Whether to generate a sitemap |
|
||||
| `sitemap.redirect` | `true` | Redirect `/sitemap` to `/sitemap.xml` |
|
||||
| `sitemap.locale` | `'en'` | Locale for the sitemap XSL stylesheet |
|
||||
| `sitemap.generator` | Built-in generator | A callable that receives a `SitemapIndex` instance. See [customizing the sitemap](2_customization/05_sitemap) |
|
||||
| `sitemap.changefreq` | `'weekly'` | Default change frequency. Static value or callable |
|
||||
| `sitemap.priority` | Calculated from depth | Homepage gets `1.0`, each level deeper subtracts `0.2`, minimum `0.2` |
|
||||
| `sitemap.groupByTemplate` | `false` | Split the sitemap into separate files per template |
|
||||
| `sitemap.excludeTemplates` | `['error']` | Templates to exclude from the sitemap |
|
||||
|
||||
## AI Assist
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ---------- | --------------------------------- |
|
||||
| `ai.enabled` | `true` | Whether AI features are available |
|
||||
| `ai.provider` | `'openai'` | The active provider ID |
|
||||
| `ai.providers` | See below | Provider configurations |
|
||||
|
||||
Each provider has a `driver` class and a `config` array. The config options depend on the driver. All built-in drivers share `apiKey` (required), `model`, `endpoint`, and `timeout`. The OpenAI driver also accepts `organization`.
|
||||
|
||||
| Provider | Driver | Default model | Default endpoint |
|
||||
| ------------ | ----------- | -------------------------------- | --------------------------------------------------------- |
|
||||
| `openai` | `OpenAi` | `gpt-5-mini-2025-08-07` | `https://api.openai.com/v1/responses` |
|
||||
| `anthropic` | `Anthropic` | `claude-4-5-haiku` | `https://api.anthropic.com/v1/messages` |
|
||||
| `gemini` | `Gemini` | `gemini-3.1-flash-lite-preview` | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `openrouter` | `OpenAi` | `openai/gpt-5-nano` | `https://openrouter.ai/api/v1/responses` |
|
||||
|
||||
The Gemini driver authenticates via API key as a query parameter (not a header). All providers default to a timeout of 120 seconds. See [customizing AI Assist](2_customization/06_ai-assist) for adding your own provider.
|
||||
|
||||
## IndexNow
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | ---------------------------- | ---------------------------------------------------------- |
|
||||
| `indexnow.enabled` | `true` | Whether to ping search engines on page changes |
|
||||
| `indexnow.searchEngine` | `'https://api.indexnow.org'` | IndexNow API endpoint. One engine propagates to all others |
|
||||
| `indexnow.rules` | `[]` | Invalidation rules for related pages |
|
||||
|
||||
Rules map a match pattern to invalidation targets. Match patterns can be a URL glob (`'/blog/*'`), a template name (`'article'`), or a wildcard (`'*'`).
|
||||
|
||||
| Target | Value | Description |
|
||||
| ----------- | --------------- | ---------------------------------------------------------- |
|
||||
| `parent` | `true` or `int` | Invalidate the direct parent (`true`) or N levels up |
|
||||
| `children` | `true` or `int` | Invalidate all descendants (`true`) or up to N levels deep |
|
||||
| `siblings` | `true` | Invalidate all siblings at the same level |
|
||||
| `urls` | `string[]` | Specific URLs to invalidate |
|
||||
| `templates` | `string[]` | Invalidate all pages with these templates |
|
||||
|
||||
```php
|
||||
'indexnow' => [
|
||||
'rules' => [
|
||||
'article' => ['parent' => true, 'urls' => ['/blog', '/']],
|
||||
'product' => ['parent' => true, 'siblings' => true, 'templates' => ['category']],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Search Console
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `searchConsole.enabled` | `true` | Whether the Search Console integration is active |
|
||||
| `searchConsole.credentials` | `null` | Google OAuth credentials array. See [GSC setup](2_customization/07_gsc-setup) |
|
||||
| `searchConsole.tokenPath` | `site/config/.gsc-tokens.json` | Where OAuth tokens are stored |
|
||||
|
||||
## Components
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------- | ---------------------------------- | ------------------------- |
|
||||
| `components.meta` | `tobimori\Seo\Meta` | Meta tag generation class |
|
||||
| `components.ai` | `tobimori\Seo\Ai` | AI Assist class |
|
||||
| `components.indexnow` | `tobimori\Seo\IndexNow` | IndexNow class |
|
||||
| `components.schema` | `tobimori\Seo\SchemaSingleton` | Schema.org store class |
|
||||
| `components.gsc` | `tobimori\Seo\GoogleSearchConsole` | Search Console class |
|
||||
|
||||
See [extending the plugin](2_customization/11_plugin-extensions) for details on replacing components.
|
||||
37
site/plugins/kirby-seo/docs/3_reference/1_permissions.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
title: Permissions
|
||||
intro: Control access to plugin features by user role
|
||||
---
|
||||
|
||||
Kirby SEO registers permissions that you can restrict per [user role](https://getkirby.com/docs/guide/users/permissions). By default, all permissions are granted.
|
||||
|
||||
## Available permissions
|
||||
|
||||
| Permission | Controls |
|
||||
| ----------------- | -------------------------------------------------------------------------------- |
|
||||
| `tobimori.seo.ai` | Access to all AI Assist features: generating, editing, and customizing meta text |
|
||||
|
||||
More permissions will be added in future releases.
|
||||
|
||||
## Restricting access
|
||||
|
||||
Set a permission to `false` in a role's blueprint to deny it:
|
||||
|
||||
```yaml
|
||||
# site/blueprints/users/editor.yml
|
||||
|
||||
title: Editor
|
||||
permissions:
|
||||
tobimori.seo:
|
||||
ai: false
|
||||
```
|
||||
|
||||
You can also deny all current and future permissions at once using a wildcard:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
tobimori.seo:
|
||||
*: false
|
||||
```
|
||||
|
||||
Users without a permission will not see the corresponding UI elements in the Panel, and API requests will be rejected.
|
||||
1
site/plugins/kirby-seo/index.css
Normal file
5
site/plugins/kirby-seo/index.js
Normal file
100
site/plugins/kirby-seo/index.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
@include_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Data\Json;
|
||||
use Spatie\SchemaOrg\Schema;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use tobimori\Seo\AltText;
|
||||
|
||||
if (
|
||||
version_compare(App::version() ?? '0.0.0', '5.0.0', '<') === true ||
|
||||
version_compare(App::version() ?? '0.0.0', '6.0.0', '>=') === true
|
||||
) {
|
||||
throw new Exception('Kirby SEO requires Kirby 5');
|
||||
}
|
||||
|
||||
App::plugin(
|
||||
'tobimori/seo',
|
||||
// TODO: license
|
||||
extends: [
|
||||
'options' => require __DIR__ . '/config/options.php',
|
||||
'sections' => require __DIR__ . '/config/sections.php',
|
||||
'areas' => require __DIR__ . '/config/areas.php',
|
||||
'siteMethods' => require __DIR__ . '/config/site-methods.php',
|
||||
'pageMethods' => require __DIR__ . '/config/page-methods.php',
|
||||
'hooks' => require __DIR__ . '/config/hooks.php',
|
||||
'routes' => require __DIR__ . '/config/routes.php',
|
||||
'fields' => require __DIR__ . '/config/fields.php',
|
||||
'fieldMethods' => [
|
||||
'toAltText' => fn ($field) => AltText::fromField($field),
|
||||
],
|
||||
'permissions' => [
|
||||
'ai' => true,
|
||||
],
|
||||
'snippets' => [
|
||||
'seo/prompts/introduction' => __DIR__ . '/snippets/prompts/introduction.php',
|
||||
'seo/prompts/content' => __DIR__ . '/snippets/prompts/content.php',
|
||||
'seo/prompts/meta' => __DIR__ . '/snippets/prompts/meta.php',
|
||||
'seo/prompts/site-meta' => __DIR__ . '/snippets/prompts/site-meta.php',
|
||||
'seo/prompts/tasks/title' => __DIR__ . '/snippets/prompts/tasks/title.php',
|
||||
'seo/prompts/tasks/description' => __DIR__ . '/snippets/prompts/tasks/description.php',
|
||||
'seo/prompts/tasks/og-description' => __DIR__ . '/snippets/prompts/tasks/og-description.php',
|
||||
'seo/prompts/tasks/site-description' => __DIR__ . '/snippets/prompts/tasks/site-description.php',
|
||||
'seo/prompts/tasks/og-site-description' => __DIR__ . '/snippets/prompts/tasks/og-site-description.php',
|
||||
'seo/prompts/tasks/alt-text' => __DIR__ . '/snippets/prompts/tasks/alt-text.php',
|
||||
'seo/schemas' => __DIR__ . '/snippets/schemas.php',
|
||||
'seo/head' => __DIR__ . '/snippets/head.php',
|
||||
'seo/robots.txt' => __DIR__ . '/snippets/robots.txt.php',
|
||||
],
|
||||
'templates' => [
|
||||
'sitemap' => __DIR__ . '/templates/sitemap.php',
|
||||
'sitemap.xml' => __DIR__ . '/templates/sitemap.xml.php',
|
||||
'sitemap.xsl' => __DIR__ . '/templates/sitemap.xsl.php',
|
||||
],
|
||||
'blueprints' => [
|
||||
'seo' => require __DIR__ . '/blueprints/seo.php',
|
||||
'seo/site' => require __DIR__ . '/blueprints/seo.php',
|
||||
'seo/page' => require __DIR__ . '/blueprints/seo.php',
|
||||
'seo/fields/og-image' => require __DIR__ . '/blueprints/fields/og-image.php',
|
||||
'seo/fields/og-group' => __DIR__ . '/blueprints/fields/og-group.yml',
|
||||
'seo/fields/meta-group' => __DIR__ . '/blueprints/fields/meta-group.yml',
|
||||
'seo/fields/title-template' => __DIR__ . '/blueprints/fields/title-template.yml',
|
||||
'seo/fields/robots' => require __DIR__ . '/blueprints/fields/robots.php',
|
||||
'seo/fields/site-robots' => require __DIR__ . '/blueprints/fields/site-robots.php',
|
||||
'seo/fields/social-media' => require __DIR__ . '/blueprints/fields/social-media.php',
|
||||
],
|
||||
// get all files from /translations and register them as language files
|
||||
'translations' => A::keyBy(
|
||||
A::map(
|
||||
Dir::files(__DIR__ . '/translations'),
|
||||
function ($file) {
|
||||
$translations = [];
|
||||
foreach (Json::read(__DIR__ . '/translations/' . $file) as $key => $value) {
|
||||
$translations["seo.{$key}"] = $value;
|
||||
}
|
||||
|
||||
return A::merge(
|
||||
['lang' => F::name($file)],
|
||||
$translations
|
||||
);
|
||||
}
|
||||
),
|
||||
'lang'
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
if (!function_exists('schema')) {
|
||||
function schema($type)
|
||||
{
|
||||
if (!class_exists('Spatie\SchemaOrg\Schema')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Schema::{$type}();
|
||||
}
|
||||
}
|
||||
21
site/plugins/kirby-seo/lefthook.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
pre-commit:
|
||||
piped: true
|
||||
commands:
|
||||
build:
|
||||
priority: 1
|
||||
run: pnpm run build && git add index.css index.js
|
||||
eslint:
|
||||
priority: 2
|
||||
glob: "src/*.{js,vue}"
|
||||
run: pnpm exec eslint --fix {staged_files}
|
||||
stage_fixed: true
|
||||
format:
|
||||
priority: 2
|
||||
glob: "src/*.{js,vue}"
|
||||
run: pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}
|
||||
stage_fixed: true
|
||||
php-cs-fixer:
|
||||
priority: 2
|
||||
glob: "*.php"
|
||||
run: PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.dist.php {staged_files}
|
||||
stage_fixed: true
|
||||