Set up Kirby site structure, plugins and blueprints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-05-13 17:30:38 +02:00
parent 37eb4e2183
commit 64ea6acce8
216 changed files with 12470 additions and 7 deletions

View file

@ -22,7 +22,10 @@
},
"require": {
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"getkirby/cms": "^5.2"
"getkirby/cms": "^5.2",
"belugadigital/kirby-navigation": "^5.0",
"tobimori/kirby-seo": "^2.0.0-beta.4",
"scottboms/promote-button": "^1.1"
},
"config": {
"allow-plugins": {

186
composer.lock generated
View file

@ -4,8 +4,70 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "da1c3a8eb3e9e3b252ad405f32a3f585",
"content-hash": "21be036dcf5cc9ba09fe1c94fe04608d",
"packages": [
{
"name": "belugadigital/kirby-navigation",
"version": "5.0.11",
"source": {
"type": "git",
"url": "https://github.com/chrisbeluga/kirby-navigation.git",
"reference": "7ceaca30b37fd2c8ca664b1c046637cabd7f9802"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chrisbeluga/kirby-navigation/zipball/7ceaca30b37fd2c8ca664b1c046637cabd7f9802",
"reference": "7ceaca30b37fd2c8ca664b1c046637cabd7f9802",
"shasum": ""
},
"require": {
"getkirby/cms": "^4.0 || ^5.0",
"getkirby/composer-installer": "^1.2",
"php": ">=8.1.0 <8.6.0"
},
"type": "kirby-plugin",
"extra": {
"installer-name": "kirby-navigation"
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Martin",
"email": "chris@builtbybeluga.com",
"homepage": "https://builtbybeluga.com/"
},
{
"name": "Ahmet Bora",
"email": "ahmet@getkirby.com",
"homepage": "https://owebstudio.com/"
},
{
"name": "Gabor Horvath"
},
{
"name": "Immo Seebörger",
"homepage": "https://diesachbearbeiter.de/"
}
],
"description": "Kirby 5 field for hierarchical menus with drag & drop level indentation.",
"homepage": "https://github.com/chrisbeluga/kirby-navigation",
"keywords": [
"field",
"kirby",
"kirby-cms",
"kirby-plugin",
"menu",
"navigation"
],
"support": {
"issues": "https://github.com/chrisbeluga/kirby-navigation/issues",
"source": "https://github.com/chrisbeluga/kirby-navigation/tree/5.0.11"
},
"time": "2026-04-29T08:06:27+00:00"
},
{
"name": "christian-riesen/base32",
"version": "1.6.0",
@ -721,6 +783,56 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "scottboms/promote-button",
"version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/scottboms/kirby-promote-button.git",
"reference": "5143cd29ea211e286483c31eda2e47238ae81111"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/scottboms/kirby-promote-button/zipball/5143cd29ea211e286483c31eda2e47238ae81111",
"reference": "5143cd29ea211e286483c31eda2e47238ae81111",
"shasum": ""
},
"require": {
"getkirby/cms": "^5.0",
"getkirby/composer-installer": "^1.1",
"php": ">8.1.0 <8.4.0"
},
"type": "kirby-plugin",
"extra": {
"installer-name": "promote-button"
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Scott Boms",
"email": "plugins@scottboms.com",
"homepage": "https://scottboms.com"
}
],
"description": "Promote Panel Button for Kirby.",
"homepage": "https://github.com/scottboms/kirby-promote-button",
"keywords": [
"kirby",
"kirby-cms",
"kirby-plugin",
"kirby5",
"kirby5-plugin"
],
"support": {
"docs": "https://github.com/scottboms/kirby-promote-button/blob/main/README.md",
"issues": "https://github.com/scottboms/kirby-promote-button/issues",
"source": "https://github.com/scottboms/kirby-promote-button"
},
"time": "2025-12-24T07:14:57+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.0",
@ -1207,12 +1319,82 @@
}
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "tobimori/kirby-seo",
"version": "2.0.0-beta.4",
"source": {
"type": "git",
"url": "https://github.com/tobimori/kirby-seo.git",
"reference": "7a364eaccefc69cf82a164f8eeefd2fa50869985"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tobimori/kirby-seo/zipball/7a364eaccefc69cf82a164f8eeefd2fa50869985",
"reference": "7a364eaccefc69cf82a164f8eeefd2fa50869985",
"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.4"
},
"funding": [
{
"url": "https://plugins.andkindness.com/seo/preorder",
"type": "custom"
},
{
"url": "https://github.com/tobimori",
"type": "github"
}
],
"time": "2026-04-16T15:38:48+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {
"tobimori/kirby-seo": 10
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {

View file

@ -0,0 +1,5 @@
Title: Archives
----
Uuid: txrmlqm6zlaxszpv

View file

@ -0,0 +1,5 @@
Title: Par année universitaire
----
Uuid: dthnb8i5jun6udbd

View file

@ -0,0 +1,5 @@
Title: Alumni · Galerie des diplômés
----
Uuid: uslnjr1kcrh0fksh

View file

@ -0,0 +1,5 @@
Title: Ressources
----
Uuid: mtho0strg9viiuxh

View file

@ -0,0 +1,5 @@
Title: VAE · Formation continue
----
Uuid: 3ke7tgzd4zoywg1f

View file

@ -0,0 +1,5 @@
Title: Insertion professionnelle
----
Uuid: nnt37rdwbcxzvqbs

View file

@ -0,0 +1,5 @@
Title: Art · DNA · DNSEP
----
Uuid: ct3w51s4y6c2a1oo

View file

@ -0,0 +1,5 @@
Title: Design · DNA · DNSEP
----
Uuid: vezdljcloi7uqri1

View file

@ -0,0 +1,5 @@
Title: Inscriptions / concours
----
Uuid: 3xi7toojd2vk7wbj

View file

@ -0,0 +1,5 @@
Title: Enseignement supérieur
----
Uuid: 2jl6go9jucceh4ae

View file

@ -0,0 +1,5 @@
Title: Agenda
----
Uuid: j6tdqcfadwdpuwzx

View file

@ -0,0 +1,5 @@
Title: Événements phares
----
Uuid: 5bflrej3tqifinte

View file

@ -0,0 +1,5 @@
Title: International
----
Uuid: ulmylokycamwwg5f

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -0,0 +1 @@
Uuid: hjm3ouicarfkieqx

View file

@ -0,0 +1,85 @@
Title: Jean-Marc Bullet, designer
----
Cover: - file://hjm3ouicarfkieqx
----
Date: 2026-05-06
----
Category: visio
----
Infos: <p>14h à 15h30, salle 245<br>[VISIO-CONFÉRENCE] avec Jean-Marc Bullet, designer<br>Parcours <u><em>Salle des fêtes</em></u>, cycle 1, Design</p>
----
Presentation: <p>Jean-Marc Bullet est un artiste designer basé en Martinique, dont la pratique se situe à lintersection de linnovation sociale et de la préservation de lenvironnement. Diplômé de la Haute École des Arts du Rhin (HEAR) de Strasbourg en 2005, puis de lENSCI-Les Ateliers à Paris en 2007, il utilise le design comme un levier pour créer du lien et repenser nos modes de vie.</p><p>Son travail explore particulièrement la manière dont le design peut renforcer les liens sociaux et culturels au sein dun territoire, tout en interrogeant nos relations écologiques dans un contexte post-colonial. Plus que la simple recherche de solutions techniques, Jean-Marc Bullet conçoit des dispositifs destinés à engager le dialogue avec les habitants et à faire émerger des projets collaboratifs.</p><p>Son parcours international la mené en Chine, où il a travaillé dans les industries du marbre et de lénergie solaire pour réduire lempreinte écologique des entreprises. Cette expertise environnementale a été récompensée en 2019 par un prix de design allemand pour son projet de composteur, Nestot.</p><p>Récemment, ses projets ont acquis une visibilité majeure : • En 2021, il est lauréat du programme « Mondes Nouveaux » pour son projet Presquune île, une expérience sensorielle visant à sensibiliser le public aux enjeux écologiques et sociaux de la mangrove.• La même année, il collabore avec léditeur américain CB2 pour créer une ligne de mobilier inspirée par lhistoire de la diaspora caribéenne.• Plus récemment, il a été résident à la Villa Albertine à New York, où il a capté la voix de designers africains américains et africains caraibéens sur linfluence de lhistoire et des origines sur la pratique du design dans la societe americaine.</p><p>Depuis 2022, il a co-fondé Heritage des Iles qui favorise laccès de la diaspora antillaise à son matrimoine culturel en recréant le rituel caribéen de linfusion. Cela comprend des infusions fonctionnels, des produits au cacao, et des accessoires de fabrication locale et artisanale.</p><p><a href="https://www.instagram.com/jeanmarcbullet_atelier/">https://www.instagram.com/jeanmarcbullet_atelier/</a></p>
----
Metatitle:
----
Metatemplate:
----
Usetitletemplate: true
----
Metadescription:
----
Ogtemplate:
----
Useogtemplate: true
----
Ogdescription:
----
Ogimage:
----
Cropogimage:
----
Robotsindex: default
----
Robotsfollow: default
----
Robotsarchive: default
----
Robotsimageindex: default
----
Robotssnippet: default
----
Metainherit:
----
Uuid: fydtohvqvpeapjpa

5
content/news/news.txt Normal file
View file

@ -0,0 +1,5 @@
Title: Actualités
----
Uuid: 7zubbxe67qf7w2ml

View file

@ -0,0 +1,5 @@
Title: Présentation générale
----
Uuid: duykm3jihethqebg

View file

@ -0,0 +1,3 @@
Title: Offre pédagogique
----

View file

@ -0,0 +1,3 @@
Title: Inscriptions
----

View file

@ -0,0 +1,5 @@
Title: Cours publics
----
Uuid: lpe5tjcw2qdrzctg

View file

@ -1 +1,138 @@
Title: Site Title
Title: ebabx
----
Navigation:
-
type: page
id: presentation
uuid_uri: page://duykm3jihethqebg
target: ""
uuid: 40hq9evspg4
children: [ ]
default_link_text: ""
default_page_url: http://localhost:8888/presentation
default_page_title: Présentation générale
default_link_title: ""
-
type: page
id: public-courses
uuid_uri: page://lpe5tjcw2qdrzctg
target: ""
uuid: iddjgl4jmbo
children: [ ]
default_link_text: ""
default_page_url: http://localhost:8888/public-courses
default_page_title: Cours publics
default_link_title: ""
-
type: page
id: higher-education
uuid_uri: page://2jl6go9jucceh4ae
target: ""
uuid: wgt76xuqup9
children:
-
type: page
id: higher-education/cursus-art
uuid_uri: page://ct3w51s4y6c2a1oo
target: ""
uuid: 2cwpqh9y4et
children: [ ]
default_link_text: ""
default_page_url: >
http://localhost:8888/higher-education/cursus-art
default_page_title: Art · DNA · DNSEP
default_link_title: ""
-
type: page
id: higher-education/cursus-design
uuid_uri: page://vezdljcloi7uqri1
target: ""
uuid: y76nqmghc7j
children: [ ]
default_link_text: ""
default_page_url: >
http://localhost:8888/higher-education/cursus-design
default_page_title: Design · DNA · DNSEP
default_link_title: ""
default_link_text: ""
default_page_url: http://localhost:8888/higher-education
default_page_title: Enseignement supérieur
default_link_title: ""
-
type: page
id: home/news
uuid_uri: page://7zubbxe67qf7w2ml
target: ""
uuid: ugbwfrqi03s
children: [ ]
default_link_text: ""
default_page_url: http://localhost:8888/home/news
default_page_title: Actualités
default_link_title: ""
-
type: page
id: higher-education/admissions
uuid_uri: page://3xi7toojd2vk7wbj
target: ""
uuid: j6za8mfcda
children: [ ]
default_link_text: ""
default_page_url: >
http://localhost:8888/higher-education/admissions
default_page_title: Inscriptions / concours
default_link_title: ""
----
Metatemplate:
----
Metadescription:
----
Ogtemplate:
----
Ogdescription:
----
Ogsitename:
----
Ogimage:
----
Cropogimage: true
----
Robotsindex:
----
Robotsfollow:
----
Robotsarchive:
----
Robotsimageindex:
----
Robotssnippet:
----
Socialmediaaccounts:

View file

@ -0,0 +1,7 @@
title: Inscriptions / concours
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Agenda
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Alumni · Galerie des diplômés
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Année universitaire
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Archives
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Insertion professionnelle
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: VAE · Formation continue
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Art · DNA · DNSEP
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Design · DNA · DNSEP
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -19,3 +19,4 @@ columns:
files:
type: files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Enseignement supérieur
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Accueil
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: International
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,67 @@
title: Actualité
buttons:
promote: true
preview: true
settings: true
languages: true
tabs:
content:
label: contenu
columns:
- width: 1/3
fields:
cover:
label: Image de couverture
type: files
multiple: false
layout: cards
image:
ratio: 16/10
cover: true
size: huge
date:
type: date
display: DD/MM/YYYY
required: true
width: 1/2
category:
label: Categorie
type: select
multiple: false
options:
exhibition: Exposition
event: Événement
visio: Visio-conférence
workshop: Workshop
other:
- width: 2/3
fields:
gallery:
label: Galerie
type: files
layout: cards
image:
back: #000000
infos:
type: writer
nodes: false
marks:
- italic
- bold
- underline
- email
- link
presentation:
label: Présentation
type: writer
nodes: false
marks:
- italic
- bold
- underline
- email
- link
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,18 @@
title: Actualités
tabs:
content:
label: contenu
icon: grid
sections:
news:
type: pages
layout: cards
template: news-item
info: "[{{ page.category.label }}] le {{ page.date.toDate('d/m/Y') }}"
image:
cover: true
ratio: 16/10
query: page.cover.toFile
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Présentation générale
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Offre pédagogique
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Cours publics
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Inscriptions
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Ressources
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -0,0 +1,7 @@
title: Événements phares
tabs:
content:
label: contenu
files: tabs/files
seo: seo/page

View file

@ -1,5 +1,13 @@
title: Site
sections:
pages:
type: pages
tabs:
navigation:
icon: folder-structure
fields:
navigation:
label: Navigation
type: navigation
levels: 3
width: 1/2
multilang: false
seo: seo/site

View file

@ -0,0 +1,17 @@
label: Fichiers
icon: attachment
columns:
- width: 1/4
fields:
manageFilesInfo:
label: false
type: info
text: À droite, tous les fichiers que stocke la page. Supprimez les fichiers inutilisés pour éviter de surcharger inutilement le serveur.
- width: 3/4
sections:
filesSection:
label: Fichiers
type: files
translate: false
batch: true

22
site/config/config.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
'debug' => true,
'panel' => [
'menu' => require_once __DIR__ . '/menu.php',
'css' => '/assets/css/panel.css',
],
'scottboms.promote' => [
'services' => ['mastodon', 'linkedin', 'instagram'],
'mastodon' => [
'username' => 'demo',
'url' => 'mastodon.social',
],
'linkedin' => [
'username' => 'demo',
],
'instagram' => [
'username' => 'demo',
],
],
];

30
site/config/menu.php Normal file
View file

@ -0,0 +1,30 @@
<?php
/**
* Helper : génère un item de menu avec détection automatique du lien actif.
*/
function menuItem(string $id, string $label, string $icon, string $link): array
{
return [
'label' => $label,
'icon' => $icon,
'link' => $link,
'current' => function () use ($link): bool {
return Str::contains(Kirby\Cms\App::instance()->path(), $link);
},
];
}
return [
'site' => [
'label' => 'Accueil',
'icon' => 'home',
'current' => function (): bool {
return Str::contains(Kirby\Cms\App::instance()->path(), '/site');
},
],
'news' => menuItem('news', 'Actualités', 'bell', 'pages/news'),
'-',
'users',
'system',
];

2
site/logs/promote.log Normal file
View file

@ -0,0 +1,2 @@
[2026-05-13 15:24:57][debug][mastodon] Final payload: {"status":"Jean-Marc Bullet, designer, en visio conf\u00e9rence le 6 mai \u00e0 14h. Plus d'info sur https:\/\/ebabx.fr\/actualites\/jean-marc-bullet-designer","language":"en","visibility":"public"}
[2026-05-13 15:24:58][debug][mastodon] Response HTTP code: 401

View file

@ -0,0 +1,6 @@
composer.lock
package-lock.json
.idea
.cache
.DS_Store
node_modules

View file

@ -0,0 +1,177 @@
# Kirby Navigation Field
A Navigation field for [Kirby CMS](https://getkirby.com).
## Preview
![](https://github.com/chrisbeluga/kirby-navigation/blob/main/navigation-demo-1.gif)
## Installation & Usage
Copy plugin files to your plugin's directory or install via composer with `composer require belugadigital/kirby-navigation`
Note that this Composer package name (belugadigital/kirby-navigation) differs from the GitHub repository URL (chrisbeluga/kirby-navigation).
## Kirby compatibility table
| Kirby version | Compatible plugin version |
|:--------------|:--------------------------|
| ^5.0 | ^5.0 |
| ^4.8 | ^4.2 |
| ^4.0 | ^4.0 |
| ^3.7 | ^3.0 |
| ^3.6 | ^2.0 |
| ^3.5 | ^1.0 |
## Usage
Add the following blueprint to wherever you would like the navigation field to appear:
```yaml
fields:
navigation:
label: Navigation
type: navigation
levels: 10
help: Description of menu or where it is used
width: 1/2
multilang: false
```
Or use the following minimalist blueprint without extra options:
```yaml
fields:
navigation:
label: Navigation
type: navigation
```
The following example shows how you can output a menu from a template file, regardless of how many levels deep the menu is. This example assumes that the "site" blueprint contains a navigation field called "navigation":
```php
<?php echo $site->navigation()->toNavigationMarkup(); ?>
```
If using the site as a headless CMS or would like to consume your menu in JS you can use the following field method to return a nested array of menu items:
```php
<?php $site->navigation()->toNavigationArray(); ?>
```
Or when using Kirby Query language
```json
{
"query": "site",
"select": {
"title": "site.title",
"navigation": "site.navigation.toNavigationArray"
}
}
```
If you want full control over your menu and want to customize the markup, you can copy the navigation.php and navigation_item.php files from the plugin's snippets directory to your /site/snippets directory, and customize them there.
This is the recommended way of markup customization.
For example, to add class="navigation-item navigation-item-X" to each link item, where X is the depth level of the given link, you can add the following line to your copy of navigation_item.php:
```php
$attributes['class']='navigation-item navigation-item-' . $depth;
```
If you prefer to use a foreach to create the menu, or if you are upgrading from an older version of this plugin, the foreach loop could look something like this:
```php
<?php if ($items=$site->navigation()->toNavigationStructure()): ?>
<ul>
<?php foreach($items as $item): ?>
<li>
<a href="<?php echo $item->url(); ?>" <?php e($item->isOpen()->value(), 'aria-current="page"') ?> <?php e($item->isChildOpen()->value(), 'class="active"') ?>>
<?php echo Str::esc($item->text(), 'html') ?>
</a>
<?php if($item->children()->isNotEmpty()): ?>
<ul>
<?php foreach($item->children()->toStructure() as $child): ?>
<li>
<a href="<?php echo $child->url() ?>" <?php e($child->isOpen()->value(), 'aria-current="page"') ?>>
<?php echo Str::esc($child->text(), 'html') ?>
</a>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
```
As you can see, $item->isOpen()->value() can be used in this foreach() to check whether the given menu item is the current page, and $item->isChildOpen()->value() can be used to check whether any of the child menu items is the current page.
## Nesting limit
Nesting limit is set to 10 by default. To adjust the maximum number of levels, use the "levels" option in the blueprint.
## Multi-language support
The plugin supports multiple languages in two ways.
- In "normal" mode, the navigation field functions like any other content field: you need to add the necessary links to the field for each language, and set the link text and link title for each language (translating). For example, if you have 3 languages and a navigation field with 5 links, you will need to add the 5 links 3 times.
- In "multilang" mode, add the "multilang: true" option to the field blueprint. This allows you to add the necessary links only once for all languages, and edit the link text and link title separately for each language. For example, if you have 3 languages and a navigation field with 5 links, you will only need to add the 5 links once, and they will be shared across all languages. You can still translate the link text and link title, if needed.
This means that if you simply add 5 Kirby pages to the field in multilang mode and don't edit the links, the link text will be displayed in the language of the current page, and will link to the corresponding page URL in that language.
The plugin allows you to add "Kirby Pages" and "Custom Links" to the navigation field. For "Kirby Pages", the page title will be the default value of the link text, if no custom link text is entered. This means that if you simply add 5 Kirby pages to the field in multilang mode and don't edit the links, you will see the language-specific page title and page URL in the generated markup.
## What's new in version 4.0?
Changes worth mentioning:
- It works with Kirby 4
- New feature: Multi-language support, as described earlier
- New feature: The plugin now uses permanent page IDs to identify pages, instead of using the 'id' (slug) as identification. This allows pages to be renamed or moved without breaking the page links.
- Data: the structure of the field content has been changed to support permanent page IDs and multiple languages. However, the 4.0 version of the plugin is backwards compatible with the field content saved by the 3.7 version of the plugin.
- UI: The "edit" button has been moved out of the options dropdown menus.
- UI: Better icons are now used for editing the links.
- UI: The 'id' and 'url' values of 'Kirby page' links are no longer editable.
- Markup: the current language and the actual values of the page title and page URL are taken into account when generating the markup for the field in the template.
- Markup: link text and link attributes are properly escaped to prevent potential issues
## What's new in version 4.1?
New features:
- The 'class' and 'target' values of links are now editable
- The 'anchor' values of 'Kirby page' links are now editable
- The 'title' textfield can be hidden, if you do not need it
- The 'popup' toggle can be hidden, if you do not need it
Different sites have different needs, so the editable fields are configurable via /site/config/config.php.
Here are the available options that you can use in your config.php, and their default values:
```php
return [
'chrisbeluga.navigation' => [
'edit_title' => true,
'edit_popup' => true,
'edit_target' => false,
'edit_class' => false,
'edit_anchor' => false,
],
];
```
For example, if you want to customize the 'target' value of your links, then set 'chrisbeluga.navigation.edit_target' to TRUE. This will replace the simple 'Popup' toggle with a 'Target' textfield, allowing you to set a link target, such as '_parent' or '_top'.
If you want to add an anchor value to your 'Kirby page' links, for example to have an URL such as /en/contact#locations, set 'chrisbeluga.navigation.edit_anchor' to TRUE. You can enter 'locations' as anchor, and '#locations' will be appended to the page URL of the link.
If you use the recommended way to output the navigation markup from your template (such as $site->navigation()->toNavigationMarkup() in case of a field called 'navigation'), then any target, class and anchor values will be included automatically in the generated markup.
## What's new in version 5.0?
Works with Kirby 5.0
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
[MIT](https://choosealicense.com/licenses/mit/)

View file

@ -0,0 +1,49 @@
{
"name": "chrisbeluga/kirby-navigation",
"description": "Kirby 5 field for hierarchical menus with drag & drop level indentation.",
"type": "kirby-plugin",
"keywords": [
"kirby",
"kirby-cms",
"kirby-plugin",
"navigation",
"menu",
"field"
],
"version": "5.0.11",
"license": "MIT",
"homepage": "https://github.com/chrisbeluga/kirby-navigation",
"authors": [
{
"name": "Chris Martin",
"email": "chris@builtbybeluga.com",
"homepage": "https://builtbybeluga.com/"
},
{
"name": "Ahmet Bora",
"email": "ahmet@getkirby.com",
"homepage": "https://owebstudio.com/"
},
{
"name": "Gabor Horvath"
},
{
"name": "Immo Seebörger",
"homepage": "https://diesachbearbeiter.de/"
}
],
"require": {
"php": ">=8.1.0 <8.6.0",
"getkirby/cms": "^4.0 || ^5.0",
"getkirby/composer-installer": "^1.2"
},
"config": {
"optimize-autoloader": true,
"allow-plugins": {
"getkirby/composer-installer": true
}
},
"extra": {
"installer-name": "kirby-navigation"
}
}

View file

@ -0,0 +1,83 @@
<?php
use Kirby\Uuid\Uuids;
/**
* Good to know:
* - stored Kirby links always have 'id' values, Custom links never have that
* - stored Kirby links may have 'uuid_uri' values, Custom links never have that
* - The uuid_uri can identify a page even after the page slug changes,
* so it is the preferred way of identification.
* - If uuid_uri value is not available for any reason, the plugin uses the 'id' value
* to find the desired page
* - The 'uuid' value used elsewhere in the plugin is not the same as 'uuid_uri' value.
* The 'uuid' value is kept for compatibility reasons.
*/
return function () {
return [
[
'pattern' => 'listings/([a-zA-Z0-9-]+)/(:all)',
'method' => 'GET',
'action' => function ($language_code, $path) {
$content = [];
$breadcrumbs = [];
$getData = $path !== 'site' ? true : false;
$data = $getData ? page($path) : site();
$multilang = $this->kirby()->languages()->isNotEmpty();
if ($multilang) {
if ($language_code=='default') {
// default language, use item title and url without translation
$multilang=false;
}
elseif (!$this->kirby()->languages()->find($language_code)) {
// invalid language, do nothing, just return an empty array
$data=null;
}
}
if (($data != null) && $data->hasChildren()) {
if ($getData) {
foreach ($data->children()->first()->parents()->flip() as $parent) {
if ($multilang && ($parent->content($language_code)->title()->value() != null)) {
$title=$parent->content($language_code)->title()->value();
}
else {
$title = $parent->title()->value();
}
array_push($breadcrumbs,[
'id' => $parent->id(),
'title' => $title,
]);
}
}
foreach ($data->children() as $item) {
$title = $item->title()->value();
if ($multilang && ($item->content($language_code)->title()->value() != null)) {
$title=$item->content($language_code)->title()->value();
}
array_push($content, [
// Values for page identification
'type' => 'page',
'uuid_uri' => Uuids::enabled() ? $item->uuid()->toString() : '',
'id' => $item->id(),
// Language-specific values that may change due to editing
$language_code . '_page_url' => $multilang ? $item->url($language_code) : $item->url(),
$language_code . '_page_title' => $title,
// Default values of page links that prop.php also provides
$language_code . '_link_text' => '',
$language_code . '_link_title' => '',
'children' => [],
'target' => '',
// Temporary helper values that will not be saved
'count' => $item->index()->count(),
]);
}
}
return [
'content' => $content,
'breadcrumbs' => $breadcrumbs,
];
}
],
];
};

View file

@ -0,0 +1,123 @@
<?php
use Kirby\Data\Yaml;
return [
// This method is the preferred way to get all the link items
// (with all child links) as a nested array.
// Do not use $field->toArray() in case of the navigation field.
'toNavigationArray' => function ($field) {
// Refresh items to get the current multilang URL and page titles
require __DIR__ . '/../includes/refresh_item.inc.php';
$items=$refresh_items($field->yaml(), $field->key(), $field->model());
$lang_code=kirby()->languages()->isNotEmpty() ? kirby()->language()->code() : 'default';
// Use anonymous recursive function to process child items
$process_item = function($item, $depth=1) use (&$process_item, $lang_code) {
$item['depth']=$depth;
// To help markup generation, set 'url', 'text', 'title'
// and the optional 'anchor' according to the current language
if ($item['type']=='page') {
$item['url']=$item[$lang_code . '_page_url'];
$item['text']=$item[$lang_code . '_link_text'];
$item['title']=$item[$lang_code . '_link_title'];
if (isset($item[$lang_code . '_link_anchor'])) {
$item['anchor']='#' . $item[$lang_code . '_link_anchor'];
}
else {
$item['anchor']='';
}
if ($item['text']=='') {
$item['text']=$item[$lang_code . '_page_title'];
}
if ($item['title']=='') {
$item['title']=$item[$lang_code . '_page_title'];
}
}
elseif ($item['type']=='custom') {
$item['text']=$item[$lang_code . '_link_text'];
$item['title']=$item[$lang_code . '_link_title'];
}
else {
$item['error']=TRUE;
return $item;
}
if ($item['title']==$item['text']) {
// No need to have a title
$item['title']='';
}
// Values used by previous plugin version
$item['isOpen']=kirby()->url('current') === $item['url'];
// To help markup generation and customization, put all attributes into an array
$item['attributes']=[];
if ($item['title']!='') {
$item['attributes']['title']=$item['title'];
}
if (isset($item['class']) && ($item['class']!=='')) {
$item['attributes']['class']=$item['class'];
}
if (isset($item['target']) && ($item['target']!='')) {
$item['attributes']['target']=$item['target'];
}
if ($item['url'] === kirby()->url('current')) {
$item['attributes']['aria-current']='page';
}
// process child items as well, if any
$item['isChildOpen']=FALSE;
if (!empty($item['children'])) {
foreach (array_keys($item['children']) as $key) {
$item['children'][$key]=$process_item($item['children'][$key], $depth+1);
// keep track whether any child item is active
if (!empty($item['children'][$key]['isOpen']) || !empty($item['children'][$key]['isChildOpen'])) {
$item['isChildOpen']=TRUE;
}
}
}
return $item;
};
if (is_array($items) && $items) {
foreach (array_keys($items) as $key) {
$items[$key]=$process_item($items[$key]);
}
}
return $items;
},
// This method is the preferred way to output the markup of a
// navigation field from your template files.
'toNavigationMarkup' => function ($field) {
// Refresh items to get the current multilang URL and page titles
// and set item values needed for markup generation
$items=$field->toNavigationArray();
// Generate HTML
return snippet('navigation', [
'children' => $items
]);
},
// This method is provided only for compatibility reasons,
// to help the users of older plugin versions
'toNavigationStructure' => function ($field) {
// Use anonymous recursive function to process child items
$process_item = function($key, $item) use (&$process_item) {
// Preserve any original 'id' value as 'link_id'
$item['link_id']=$item['id'] ?? '';
// Overwrite the 'id' (slug) value in link items,
// because the Structure class also uses it as index
$item['id']=$key;
// process child items as well, if any
if (!empty($item['children'])) {
foreach (array_keys($item['children']) as $child_key) {
$item['children'][$child_key]=$process_item($child_key, $item['children'][$child_key]);
}
}
return $item;
};
// Refresh items to get the current multilang URL and page titles
// and set item values needed for markup generation
$items=$field->toNavigationArray();
if (is_array($items) && $items) {
foreach ($items as $key => $item) {
$items[$key]=$process_item($key, $item);
}
}
$modified_field=$field->value(Yaml::encode($items));
return $modified_field->toStructure();
},
];

View file

@ -0,0 +1,60 @@
<?php
use Kirby\Data\Yaml;
return [
'value' => function ($value = []) {
$items=Yaml::decode($value);
// Check whether there is an item with type 'save',
// which indicates that the data was just submitted, and
// in this case no changes should be done.
// This 'save' item is always added by props.php
// This 'save' item is always removed by the save.php
// This 'save' trick is needed, because the props.php is not only
// executed when a field is loaded, but also when it is saved, and
// otherwise it would not be possible to distinguish between the two.
$save_in_progress=FALSE;
if (is_array($items) && $items) {
foreach ($items as $item) {
if (isset($item['type']) && ($item['type']==='save')) {
// saving is in progress, do nothing.
$save_in_progress=TRUE;
break;
}
}
}
if (!$save_in_progress) {
// Refresh items to get the current multilang URL and page titles
require __DIR__ . '/../includes/refresh_item.inc.php';
$items=$refresh_items($items, $this->name(), $this->model());
// Add a 'save' item. When the 'props' values are sent to Panel,
// the Field.vue will hide this item, but it will send it back,
// and then the props.php will detect it, and save.php will ignore it.
// Use a constant uuid value for this item, to avoid diff problems
$items[]=['type' => 'save', 'uuid' => 'save', 'children' => []];
}
return $items;
},
// Notify the Vue code which textfields should be editable by default.
// Notes:
// - The 'Popup' will be automatically hidden, if 'Target' is visible
// - If a certain value is set in the field, then Vue will show
// the editable textfield even if the corresponding option is false
// - These options can be set in /site/config/config.php
'edit_title' => function() {
return (bool) option('chrisbeluga.navigation.edit_title', true);
},
'edit_popup' => function() {
return (bool) option('chrisbeluga.navigation.edit_popup', true);
},
'edit_target' => function() {
return (bool) option('chrisbeluga.navigation.edit_target', false);
},
'edit_class' => function() {
return (bool) option('chrisbeluga.navigation.edit_class', false);
},
'edit_anchor' => function() {
return (bool) option('chrisbeluga.navigation.edit_anchor', false);
}
];

View file

@ -0,0 +1,78 @@
<?php
use Kirby\Data\Yaml;
use Kirby\Uuid\Uuids;
return function ($items) {
// Use anonymous recursive function to process child items
$prepare_item = function($item) use (&$prepare_item) {
if (!is_array($item) || !isset($item['type'])) {
throw new Exception('Unexpected data found in the navigation field while saving');
}
if ($item['type'] === 'page') {
// Do not store 'count' value (coming from api.php)
unset($item['count']);
// The primary way of page identification is by 'uuid_uri',
// if not available, then by 'id'.
// Although page url and page title are saved here,
// these values will be refreshed when the field data is loaded.
}
// Remove the 'error' value, it will be set again when loading field values
unset($item['error']);
// prepare child items, if any
if (!empty($item['children'])) {
foreach (array_keys($item['children']) as $key) {
$item['children'][$key]=$prepare_item($item['children'][$key]);
}
}
return $item;
};
// Remove any item with type 'save',
// indicating field data that was just submitted
// This 'save' item is always added by props.php
// This 'save' item is always removed by the save.php
// This 'save' trick is needed, because the props.php is not only
// executed when a field is loaded, but also when it is saved, and
// otherwise it would not be possible to distinguish between the two.
if (is_array($items) && $items) {
foreach (array_keys($items) as $key) {
if (isset($items[$key]['type']) && ($items[$key]['type']==='save')) {
unset($items[$key]);
}
}
if ($items) {
// When the 'save' item is deleted, there may be a gap in keys
// Make sure that array keys have no gaps, to avoid JS problems
$items=array_values($items);
}
}
if (is_array($items) && $items) {
foreach (array_keys($items) as $key) {
$items[$key]=$prepare_item($items[$key]);
}
}
// Get the 'multilang' option of the field blueprint
$blueprint_field=$this->model()->blueprint()->field($this->name());
if (!empty($blueprint_field) && !empty($blueprint_field['multilang'])) {
// If 'multilang' is set in the blueprint, then data is stored
// in external yaml file, so that it can be shared between languages
$filepathTMP = tempnam(sys_get_temp_dir(), 'navigation');
if (file_put_contents($filepathTMP, Yaml::encode($items))) {
if (rename($filepathTMP, $this->model()->root() . '/kirby-navigation---' . $this->name() . '.yml')) {
// The data was successfully saved to the external file,
// so change the field data to a simple flag that will tell
// the load function to load the external file.
$items=['multilang' => TRUE];
}
else {
}
}
else {
}
@unlink($filepathTMP);
}
return $items;
};

View file

@ -0,0 +1,252 @@
<?php
use Kirby\Data\Yaml;
use Kirby\Http\Uri;
use Kirby\Uuid\Uuids;
/**
* This file should be included wherever it is needed with the
* 'require' keyword (not 'include', and not 'require_once'!)
* Then it should be called like this to refresh an array of items
* and all its children:
* $items = $refresh_items($items, $field_name, $field_model);
* The purpose of these functions is to make it possible that only
* fixed values are saved in the field, that never (or rarely) change
* (for example id, or uuid_uri) and the values that may change
* (for example page title, page url) are calculated by these
* functions, when needed.
*
* A secondary purpose of these functions is to make multilanguage
* navigation field editing as convenient as possible.
*/
// Use anonymous recursive function to process child items
$refresh_item = function($item) use (&$refresh_item) {
if (!is_array($item)) {
throw new Exception('Unexpected data found in the navigation field');
}
if ($multilang = kirby()->languages()->isNotEmpty()) {
$language_code=kirby()->language()->code();
$default_language_code=kirby()->defaultLanguage()->code();
}
else {
$language_code='default';
$default_language_code='default';
}
// Upgrade from old data, to remain compatible with old plugin versions
if (!isset($item['type'])) {
// Add a 'type' value, that did not exist in the old plugin version
// Previous plugin versions stored Kirby links with 'id' values,
// and stored Custom links without that
$item['type'] = isset($item['id']) ? 'page' : 'custom';
if ($item['type']=='page') {
// Previous plugin versions used 'text' and 'title' values.
// The new version uses 'LANG_link_text', 'LANG_link_title',
// and 'LANG_page_title', where 'LANG' is the language code.
// The new version outputs the 'LANG_page_title' as link HTML,
// if the 'LANG_link_text' value is empty.
if (isset($item['text'])) {
$item[$default_language_code . '_link_text']=$item['text'];
unset($item['text']);
}
if (isset($item['title'])) {
$item[$default_language_code . '_link_title']=$item['title'];
unset($item['title']);
}
// Previous plugin versions used 'url' value.
// The new version uses 'LANG_page_url',
// where 'LANG' is the language code.
if (isset($item['url'])) {
$item[$default_language_code . '_page_url']=$item['url'];
unset($item['url']);
}
}
elseif ($item['type']=='custom') {
// Previous plugin versions used 'text' and 'title' values.
// The new version uses 'LANG_link_text', 'LANG_link_title',
// where 'LANG' is the language code.
if (isset($item['text'])) {
$item[$default_language_code . '_link_text']=$item['text'];
unset($item['text']);
}
if (isset($item['title'])) {
$item[$default_language_code . '_link_title']=$item['title'];
unset($item['title']);
}
}
}
if ($item['type']=='page') {
// Fetch page by the permanent 'uuid_uri', if possible, otherwise by 'id'
$page=null;
if (!empty($item['uuid_uri']) && Uuids::enabled()) {
if ($page=kirby()->page($item['uuid_uri'])) {
// Refresh the 'id' to handle any changes
$item['id'] = $page->id();
}
}
if (!$page) {
if ($page=kirby()->page($item['id'])) {
if (empty($item['uuid_uri']) && Uuids::enabled()) {
// Add a 'uuid_uri' value, that did not exist in the old plugin version
$item['uuid_uri'] = $page->uuid()->toString();
}
}
}
if ($page) {
// Refresh the 'url' and 'page_title' (in the current language)
// to handle any changes
// Remember, that a navigation field built on e.g. localhost
// should work perfectly when copied to the public site.
$item[$language_code . '_page_url'] = $multilang ? $page->url($language_code) : $page->url();
$item[$language_code . '_page_title'] = $multilang ? ($page->content($language_code)->title()->value() ?? ''): ($page->content()->title()->value() ?? '');
}
else {
// This page no longer exists.
// Set 'error' so that the item icon in panel will be 'question'
$item['error']=TRUE;
// Adjust the old url to the current site url using the latest known 'id' value
if ($multilang) {
kirby()->currentLanguage()->url() . '/' . $item['id'];
}
else {
$item[$language_code . '_page_url']=Uri::current(['path' => $item['id'], 'query' => '',])->toString();
}
}
// if no translation exists yet, add default values
if (!isset($item[$language_code . '_link_text'])) {
$item[$language_code . '_link_text']='';
}
if (!isset($item[$language_code . '_link_title'])) {
$item[$language_code . '_link_title']='';
}
if (option('chrisbeluga.navigation.edit_anchor')) {
if (!isset($item[$language_code . '_link_anchor'])) {
$item[$language_code . '_link_anchor']='';
}
}
}
elseif ($item['type']=='custom') {
// Validate the URL
if (empty($item['url']) || !V::url($item['url'])) {
$item['error'] = TRUE;
}
// if no translation exists yet, add default values
if (!isset($item[$language_code . '_link_text'])) {
$item[$language_code . '_link_text']='';
}
if (!isset($item[$language_code . '_link_title'])) {
$item[$language_code . '_link_title']='';
}
}
// Handle the popup -> target transition to remain compatible.
// Previous plugin versions used the 'popup' boolean value.
// The new version uses the 'target' string value.
// The new version uses the 'Popup' toggle UI by default,
// but provides the edit_target option to switch to 'Target' string UI.
if (isset($item['popup']) && !isset($item['target'])) {
$item['target']=$item['popup'] ? '_blank' : '';
}
unset($item['popup']);
// refresh child items, if any
if (!empty($item['children'])) {
foreach (array_keys($item['children']) as $key) {
$item['children'][$key]=$refresh_item($item['children'][$key]);
}
}
return $item;
};
$refresh_items = function($items, $field_name, $field_model) use ($refresh_item) {
// If 'multilang' is set in the field data, then the real data is stored
// in external yaml file, so that it can be shared between languages
// It is better to be prepared that the 'multilang' field option is
// enabled/disabled while
// - there is field data in primary or secondary languages
// - there is ['multilang' => TRUE] data in primary field data,
// but there is real field data in secondary language
// - there is real field data in primary language, but there is
// ['multilang' => TRUE] data in secondary language
// - the blueprint contains 'multilang: true', the primary language
// data is still stored in the page content file, not in external file
// (because it was not saved yet), but the field is edited in the
// secondary language in Panel.
// - and all kinds of similar cases.
//
$multilang_site=kirby()->languages()->isNotEmpty();
$multilang_blueprint=FALSE;
$multilang_field=FALSE;
$multilang_load=FALSE;
// Get the 'multilang' option of the field blueprint
$field_blueprint=$field_model->blueprint()->field($field_name);
if (!empty($field_blueprint) && !empty($field_blueprint['multilang'])) {
$multilang_blueprint=TRUE;
}
if (!empty($items['multilang'])) {
$multilang_field=TRUE;
}
if (!$multilang_blueprint && !$multilang_field) {
// normal operation: use $items
}
elseif ($multilang_blueprint && $multilang_field) {
// multilang operation with previous save: load from external file
$multilang_load=TRUE;
}
elseif ($multilang_blueprint && !$multilang_field) {
// multilang operation without previous save:
// - if site is multilang, and this is primary language: use $items
// - if site is multilang, and this is secondary language:
// load data from the primary language
// - if site has no languages: use $items
if (kirby()->multilang()) {
if (kirby()->language()->isDefault()) {
// good, this is easy!
}
else {
// The field data should be loaded from the primary language.
$model=$field_model;
$defaultContentTranslation=$model->translation(kirby()->defaultLanguage()->code());
$defaultContent=$defaultContentTranslation->content();
$fieldContent=$defaultContent[$field_name];
$items=Yaml::decode($fieldContent);
// Furthermore, the primary data may be ['multilang' => TRUE],
// instead of the real field data. Load the external file then.
if (is_array($items) && !empty($items['multilang'])) {
$multilang_load=TRUE;
}
}
}
else {
// good, this is easy!
}
}
else {
// not multilang operation, but data happens to be saved externally,
// so load it from there
$multilang_load=TRUE;
}
if ($multilang_load) {
$filepath=$field_model->root() . '/kirby-navigation---' . $field_name . '.yml';
if (!file_exists($filepath)) {
throw new Exception('Failed to load the navigation field data.' );
}
$contents=file_get_contents($filepath);
if ($contents===FALSE) {
throw new Exception('Failed to load the navigation field data.');
}
$items=Yaml::decode($contents);
if (!is_array($items)) {
$items=[];
}
}
if (is_array($items) && $items) {
foreach (array_keys($items) as $key) {
$items[$key]=$refresh_item($items[$key]);
}
}
return $items;
};

View file

@ -0,0 +1 @@
.k-form-input[data-v-e9c938c8]{width:100%;display:flex;position:relative;margin-bottom:2px}.k-form-input .k-form-inner[data-v-e9c938c8]{width:100%;display:flex;position:relative;align-items:center;justify-content:space-between;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.25rem;padding:.5rem .75rem}.k-form-input .k-menu-text[data-v-e9c938c8]{overflow:hidden;text-overflow:ellipsis}.k-form-input .k-form-actions[data-v-e9c938c8]{display:flex;align-items:center}.k-form-input .k-form-actions button[data-v-e9c938c8]{margin-left:20px}.k-form-input[data-v-34431234]{width:100%;display:flex;position:relative;margin-bottom:2px}.k-form-input .k-form-inner[data-v-34431234]{width:100%;display:flex;position:relative;flex-direction:column}.k-form-input .k-form-inner .k-form-config[data-v-34431234]{width:100%;border-top:0;display:flex;border:1px solid #ccc;flex-direction:column;margin-top:5px}.k-form-input .k-form-inner .k-form-config .k-form-group[data-v-34431234]{flex-grow:1;padding:1rem;display:flex;flex-direction:column}.k-form-input .k-form-inner .k-form-config .k-form-footer[data-v-34431234]{width:100%;display:flex;margin-top:2rem;align-items:center;padding:.6rem 1rem;border-top:1px solid #ccc;justify-content:space-between}.k-form-input .k-form-actions[data-v-34431234]{display:flex;flex-direction:column}.k-form-input .k-form-actions .input-handle[data-v-34431234]{padding:0 .4rem 1rem}.k-pages-dialog-navbar[data-v-571b2066]{display:flex;align-items:center;justify-content:center;margin-bottom:.5rem;padding-right:38px}.k-pages-dialog-navbar .k-button[data-v-571b2066]{width:38px}.k-pages-dialog-navbar .k-button[disabled][data-v-571b2066]{opacity:0}.k-pages-dialog-navbar .k-headline[data-v-571b2066]{flex-grow:1;text-align:center}.k-pages-dialog .k-list-item[data-v-571b2066]{cursor:pointer}.k-pages-dialog .k-list-item .k-button[data-theme=disabled][data-v-571b2066],.k-pages-dialog .k-list-item .k-button[disabled][data-v-571b2066]{opacity:.25}.k-pages-dialog .k-list-item .k-button[data-theme=disabled][data-v-571b2066]:hover{opacity:1}.navigation-field .k-field-depth{text-align:right}.navigation-field .k-field-header .k-dropdown-content{margin-top:10px}.navigation-field .k-field-header .k-dropdown-item{width:180px;height:auto;padding:8px;margin-bottom:8px}.navigation-field .k-field-header .k-dropdown-item .k-button-text{opacity:1;white-space:normal;text-align:left}.navigation-field .k-field-header .k-dropdown-item .k-button-text .k-menu-title{opacity:1;width:100%;display:block;margin-bottom:8px}.navigation-field .k-field-header .k-dropdown-item .k-button-text .k-menu-subtitle{opacity:.75;font-size:.675rem;line-height:.875rem}.navigation-field .nestable-handle{width:100%;display:block}.navigation-field .nestable-item-content{width:100%;display:flex;flex-wrap:nowrap;position:relative;align-items:center}.navigation-field .nestable-item-content:hover .nestable-handle .k-button{opacity:1;transition:all .3s ease-in-out}.navigation-field .nestable-handle{width:auto;height:auto;display:flex;flex-wrap:nowrap;position:relative;align-items:flex-start;margin-top:7px}.navigation-field .nestable-handle .k-button{opacity:.2;cursor:move;transition:all .3s ease-in-out}.navigation-field .nestable{position:relative}.navigation-field .nestable .k-column{margin-top:8px;margin-right:8px}.navigation-field .nestable .nestable-list{margin:0;padding:0 0 0 26px;list-style-type:none}.navigation-field .nestable>.nestable-list{padding:0}.navigation-field .nestable-item:first-child,.navigation-field .nestable-item-copy:first-child{margin-top:0}.navigation-field .nestable-item{position:relative}.navigation-field .nestable-item.is-dragging .nestable-list{pointer-events:none}.navigation-field .nestable-item.is-dragging *{opacity:0}.navigation-field .nestable-item.is-dragging:before{content:"";position:absolute;top:0;left:26px;right:0;bottom:0;transition:all .3s ease-in-out}.navigation-field .nestable-drag-layer{position:fixed;top:0;left:0;z-index:100;pointer-events:none}.navigation-field .nestable-rtl .nestable-drag-layer{left:auto;right:0}.navigation-field .nestable-handle{cursor:move}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,29 @@
<?php
Kirby::plugin('chrisbeluga/navigation', [
'fields' => [
'navigation' => [
'api' => require_once __DIR__ . '/config/api.php',
'props' => require_once __DIR__ . '/config/props.php',
'save' => require_once __DIR__ . '/config/save.php',
]
],
'translations' => [
'de' => require_once __DIR__ . '/languages/de.php',
'en' => require_once __DIR__ . '/languages/en.php',
'fr' => require_once __DIR__ . '/languages/fr.php',
'tr' => require_once __DIR__ . '/languages/tr.php',
],
'snippets' => [
'navigation' => __DIR__ . '/snippets/navigation.php',
'navigation_item' => __DIR__ . '/snippets/navigation_item.php',
],
'fieldMethods' => require_once __DIR__ . '/config/methods.php',
'options' => [
'edit_title' => TRUE,
'edit_popup' => TRUE,
'edit_target' => FALSE,
'edit_class' => FALSE,
'edit_anchor' => FALSE,
],
]);

View file

@ -0,0 +1,26 @@
<?php
return [
'menu.link.add' => 'Hinzufügen',
'menu.link.title' => 'Kirby Link',
'menu.link.text' => 'Fügt dem Menü eine Kirby-Seite hinzu',
'menu.custom.title' => 'Benutzerdefinierten Link',
'menu.custom.text' => 'Fügt dem Menü einen benutzerdefinierten Link hinzu, nützlich für externe URLs usw.',
'editor.label.id' => 'Id',
'editor.label.url' => 'Url',
'editor.label.text' => 'Text',
'editor.label.title' => 'Titel',
'editor.label.anchor' => 'Anker',
'editor.label.class' => 'Klasse',
'editor.label.target' => 'Ziel',
'editor.label.popup' => 'Popup',
'editor.menu.close' => 'Schließen',
'editor.menu.edit' => 'Bearbeiten',
'editor.menu.remove' => 'Löschen',
'editor.menu.duplicate' => 'Duplikat',
'modal.link.title' => 'Seiten hinzufügen',
'modal.link.breadcrumb' => 'Zurück',
'modal.custom.title' => 'Benutzerdefinierten Link hinzufügen',
'help.depth.text' => 'Maximal zulässige Tiefe:',
'help.empty.text' => 'Noch keine Menüpunkte'
];

View file

@ -0,0 +1,26 @@
<?php
return [
'menu.link.add' => 'Add',
'menu.link.title' => 'Kirby Link',
'menu.link.text' => 'Adds a Kirby page to the menu',
'menu.custom.title' => 'Custom Link',
'menu.custom.text' => 'Adds a custom link to the menu, useful for external urls etc',
'editor.label.id' => 'Id',
'editor.label.url' => 'Url',
'editor.label.text' => 'Text',
'editor.label.title' => 'Title',
'editor.label.anchor' => 'Anchor',
'editor.label.class' => 'Class',
'editor.label.target' => 'Target',
'editor.label.popup' => 'Popup',
'editor.menu.close' => 'Close Item',
'editor.menu.edit' => 'Edit Item',
'editor.menu.remove' => 'Remove Item',
'editor.menu.duplicate' => 'Duplicate Item',
'modal.link.title' => 'Add Pages',
'modal.link.breadcrumb' => 'Back',
'modal.custom.title' => 'Add Custom Link',
'help.depth.text' => 'Maximum allowed depth:',
'help.empty.text' => 'No menu items yet'
];

View file

@ -0,0 +1,26 @@
<?php
return [
'menu.link.add' => 'Ajouter',
'menu.link.title' => 'Lien Kirby',
'menu.link.text' => 'Ajouter une page Kirby à ce menu',
'menu.custom.title' => 'Lien personnalisé',
'menu.custom.text' => 'Ajouter un lien personnalisé à ce menu, utile pour les urls externes etc',
'editor.label.id' => 'Id',
'editor.label.url' => 'Url',
'editor.label.text' => 'Texte',
'editor.label.title' => 'Titre',
'editor.label.anchor' => 'Ancre',
'editor.label.class' => 'Classe',
'editor.label.target' => 'Cible',
'editor.label.popup' => 'Popup',
'editor.menu.close' => 'Fermer l\'élement',
'editor.menu.edit' => 'Éditer l\'élement',
'editor.menu.remove' => 'Supprimer l\'élement',
'editor.menu.duplicate' => 'Dupliquer l\'élement',
'modal.link.title' => 'Ajouter des pages',
'modal.link.breadcrumb' => 'Retour',
'modal.custom.title' => 'Ajouter un lien personnalisé',
'help.depth.text' => 'Profondeur maximale autorisée :',
'help.empty.text' => 'Aucun élement de menu pour l\'instant'
];

View file

@ -0,0 +1,26 @@
<?php
return [
'menu.link.add' => 'Ekle',
'menu.link.title' => 'Sayfa Bağlantısı',
'menu.link.text' => 'Menüye bir sayfa ekler',
'menu.custom.title' => 'Özel Bağlantı',
'menu.custom.text' => 'Menüye harici url\'ler vb. için yararlı olan özel bir bağlantı ekler',
'editor.label.id' => 'Id',
'editor.label.url' => 'Url',
'editor.label.text' => 'Metin',
'editor.label.title' => 'Başlık',
'editor.label.anchor' => 'Çapa',
'editor.label.class' => 'Sınıf',
'editor.label.target' => 'Hedef',
'editor.label.popup' => 'Yeni pencere',
'editor.menu.close' => 'Kapat',
'editor.menu.edit' => 'Düzenle',
'editor.menu.remove' => 'Kaldır',
'editor.menu.duplicate' => 'Çiftleme',
'modal.link.title' => 'Sayfa Ekle',
'modal.link.breadcrumb' => 'Geri',
'modal.custom.title' => 'Özel Bağlantı Ekle',
'help.depth.text' => 'İzin verilen maksimum derinlik:',
'help.empty.text' => 'Henüz menü öğesi yok'
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View file

@ -0,0 +1,13 @@
{
"name": "kirby-navigation",
"type": "module",
"scripts": {
"dev": "npx -y kirbyup src/index.js --watch",
"build": "npx -y kirbyup src/index.js"
},
"dependencies": {
"kirbyup": "^0.14.1",
"vue-nestable": "^2.6.0",
"vue-runtime-helpers": "^1.1.2"
}
}

View file

@ -0,0 +1,34 @@
<?php
/** @var array $children */
/**
* Call this snippet with the "refreshed" nested array
* of the navigation field items (and not with the field data).
* See toNavigationMarkup() in methods.php for details.
*
* This snippet recursively processes all child items.
* To generate the markup for each link item, it calls
* the 'navigation_item' snippet, allowing for easy customization.
*
* To customize this snippet, copy it to your /site/snippets/ folder
* and edit the copy.
*/
if (!isset($children) || !is_array($children) || !$children) {
return;
}
if (isset($children['multilang'])) {
// This data was not "refreshed" before calling this snippet.
return;
}
?>
<ul>
<?php foreach ($children as $item): ?>
<li>
<?php snippet('navigation_item', $item); ?>
<?php if (!empty($item['children'])): ?>
<?php snippet('navigation', ['children' => $item['children']]); ?>
<?php endif ?>
</li>
<?php endforeach; ?>
</ul>

View file

@ -0,0 +1,32 @@
<?php
/*
* This snippet is typically called by the 'navigation' snippet.
* It generates the markup for a single link item.
*
* Important variables:
* string $url Link url
* string $text Link text
* string $anchor Link anchor
* array $attributes Link attributes (title, target, class, aria-current)
*
* Other variables:
* string $type Link type (usually 'page' or 'custom')
* string $title Link title (also included in $attributes)
* int $depth Nesting level (starting with 1)
* bool $isOpen Flag indicating whether the link URL is the current URL
* bool $error Flag indicating whether the link has issues
*
* To customize this snippet, copy it to your /site/snippets/ folder
* and edit the copy.
*
* For example, to add class="navigation-item navigation-item-X"
* to each link item, where X is the depth level of the given link,
* you can add the following line to your copy of navigation_item.php:
* $attributes['class']='navigation-item navigation-item-' . $depth;
*
*/
?>
<?php
use Kirby\Cms\Html;
echo Html::tag('a', $text ?? '', array_merge(['href' => ($url ?? '') . ($anchor ?? '')], $attributes ?? []));

View file

@ -0,0 +1,628 @@
<template>
<k-field
class="k-form-field navigation-field"
v-bind:help="help"
v-bind:label="label"
v-bind:levels="levels"
v-bind:disabled="disabled"
v-bind:required="required">
<template v-slot:options>
<k-dropdown>
<k-button v-if="!disabled"
icon="add"
v-on:click="$refs.menu.toggle()">
{{ $t('menu.link.add') }}
</k-button>
<k-dropdown-content
ref="menu">
<k-dropdown-item v-on:click="modal_open('default')">
<span class="k-menu-title">
{{ $t('menu.link.title') }}
</span>
<p class="k-menu-subtitle">
{{ $t('menu.link.text') }}
</p>
</k-dropdown-item>
<k-dropdown-item
v-on:click="modal_open('custom')">
<span class="k-menu-title">
{{ $t('menu.custom.title') }}
</span>
<p class="k-menu-subtitle">
{{ $t('menu.custom.text') }}
</p>
</k-dropdown-item>
</k-dropdown-content>
</k-dropdown>
</template>
<vue-nestable
keyProp="uuid"
v-model="navigation"
childrenProp="children"
v-bind:maxDepth="computed_levels"
v-if="navigation.length">
<template
slot-scope="{ item, index }"
v-bind:item="item"
v-if="item.type!='save'">
<listDefault
v-bind:item="item"
v-bind:navigation="navigation"
v-bind:navigationdisabled="disabled"
v-on:action_add="action_add"
v-on:action_remove="action_remove">
<template
v-slot:handle
v-bind:item="item">
<VueNestableHandle v-bind:item="item">
<k-button
icon="sort"
class="input-handle"
v-bind:tooltip="$t('editor.menu.sort')">
</k-button>
</VueNestableHandle>
</template>
<template v-slot:dropdown_fields>
<k-grid v-if="item.type == 'page'">
<k-column width="1">
<k-info-field
v-bind:text="item[langkey('page_title')]"
icon="page">
</k-info-field>
<k-info-field
v-bind:text="item[langkey('page_url')]"
icon="url">
</k-info-field>
</k-column>
<k-column width="1/2">
<k-text-field
v-bind:label="$t('editor.label.text')"
v-model="item[langkey('link_text')]">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="title_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.title')"
v-model="item[langkey('link_title')]">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="anchor_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.anchor')"
v-model="item[langkey('link_anchor')]">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="class_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.class')"
v-model="item.class">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="target_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.target') + ' (_blank, _self, _parent, _top, ...)'"
v-model="item.target">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="popup_is_editable(item)">
<k-toggle-field
@input="item.target=$event ? '_blank' : ''"
:value="(item.target=='_blank' ? true : false)"
v-bind:label="$t('editor.label.popup')">
</k-toggle-field>
</k-column>
</k-grid>
<k-grid v-else-if="item.type == 'custom'">
<k-column width="1/2">
<k-text-field
v-bind:label="$t('editor.label.text')"
v-model="item[langkey('link_text')]">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="title_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.title')"
v-model="item[langkey('link_title')]">
</k-text-field>
</k-column>
<k-column width="1/2">
<k-text-field
v-bind:label="$t('editor.label.url')"
v-model="item.url">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="class_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.class')"
v-model="item.class">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="target_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.target') + ' (_blank, _self, _parent, _top, ...)'"
v-model="item.target">
</k-text-field>
</k-column>
<k-column width="1/2" v-if="popup_is_editable(item)">
<k-toggle-field
@input="item.target=$event ? '_blank' : ''"
:value="(item.target=='_blank' ? true : false)"
v-bind:label="$t('editor.label.popup')">
</k-toggle-field>
</k-column>
</k-grid>
</template>
</listDefault>
</template>
</vue-nestable>
<k-empty
v-else
icon="page">
{{ $t('help.empty.text') }}
</k-empty>
<modalDefault
v-if="modal.status"
v-bind:modal="modal.status"
v-on:modal_close="modal_close"
v-on:modal_submit="modal_submit">
<template v-slot:modal_header>
<header class="k-pages-dialog-navbar">
<template v-if="modal.type === 'default'">
<k-button
icon="angle-left"
v-on:click="action_fetch(computed_breadcrumbs)"
v-if="query.breadcrumbs.length > 0">
{{ $t('modal.link.breadcrumb') }}
</k-button>
<k-headline>
{{ $t('modal.link.title') }}
</k-headline>
</template>
<template v-if="modal.type === 'custom'">
<k-headline>
{{ $t('modal.custom.title') }}
</k-headline>
</template>
</header>
</template>
<template v-slot:modal_body>
<template v-if="modal.type === 'default'">
<listModal
v-for="(item, index) in query.content"
v-bind:key="item.uuid"
v-bind:item="item">
<template v-slot:text>
<span class="k-menu-text">{{ item[langkey('page_title')] }}</span>
</template>
<template v-slot:fetch>
<k-button
icon="angle-right"
v-if="item.count > 0"
v-on:click="action_fetch(item.id)">
</k-button>
</template>
<template v-slot:add>
<k-button
icon="add"
v-on:click="action_add(item)">
</k-button>
</template>
</listModal>
</template>
<template v-if="modal.type === 'custom'">
<div class="k-fieldset">
<k-grid>
<k-column>
<k-text-field
v-bind:label="$t('editor.label.text')"
v-model="item[langkey('link_text')]">
</k-text-field>
</k-column>
<k-column>
<k-text-field
v-bind:label="$t('editor.label.url')"
v-model="item.url">
</k-text-field>
</k-column>
<k-column v-if="popup_is_editable(item)">
<k-toggle-field
@input="item.target=$event ? '_blank' : ''"
:value="(item.target=='_blank' ? true : false)"
v-bind:label="$t('editor.label.popup')">
</k-toggle-field>
</k-column>
<k-column v-if="target_is_editable(item)">
<k-text-field
v-bind:label="$t('editor.label.target') + ' (_blank, _self, _parent, _top, ...)'"
v-model="item.target">
</k-text-field>
</k-column>
</k-grid>
</div>
</template>
</template>
</modalDefault>
<template v-slot:help>
<k-grid>
<k-column width="1/2">
<k-help
v-if="help"
class="k-field-help"
v-html="help">
</k-help>
</k-column>
<k-column width="1/2">
<k-help
v-if="computed_levels<=5"
class="k-field-help k-field-depth">
{{ $t('help.depth.text') }} <strong>{{ computed_levels }}</strong>
</k-help>
</k-column>
</k-grid>
</template>
</k-field>
</template>
<script>
// Import Components
import ListModal from './components/Lists/Modal.vue'
import ListDefault from './components/Lists/Default.vue'
import ModalDefault from './components/Modal/Default.vue'
export default {
props: {
help: String,
value: Array,
label: String,
levels: Number,
edit_title: Boolean,
edit_popup: Boolean,
edit_target: Boolean,
edit_class: Boolean,
edit_anchor: Boolean,
disabled: Boolean,
required: Boolean,
endpoints: Object,
},
components: {
ListModal,
ListDefault,
ModalDefault
},
data() {
return {
navigation: this.value || [],
modal: {type: '', status: false},
query: {content: [], breadcrumbs: []},
item: {url: '', uuid_uri: '', text: '', target: ''}
}
},
watch: {
navigation: {
handler() {
this.$emit('input', this.navigation)
},
deep: true
},
panel_content_has_diff() {
if (!this.panel_content_has_diff) {
// If previously detected changes disappeared,
// probably because of the "Discard" button in Panel,
// then reset the navigation items to the initial values.
this.navigation = this.value;
}
}
},
methods: {
modal_close() {
this.modal = {type: '', status: false};
this.$emit('close');
},
modal_open(data) {
this.modal = {type: data, status: true}
panel.dialog.open(this);
},
modal_submit() {
if (this.modal.type === 'custom') {
this.item.type='custom'
this.action_add(this.item)
this.item = {url: '', uuid_uri: '', text: '', target: ''}
}
this.modal = {type: '', status: false}
this.$emit('close')
},
action_fetch(data) {
let language = this.$panel.language.code ?? 'default';
this.$api.get(this.endpoints.field + '/listings/' + language + '/' + data)
.then((response) => {
this.query = response
})
.catch((error) => {
this.query = {content: [], breadcrumbs: []};
console.log(error)
})
},
action_remove(data) {
return this.navigation = data.haystack.filter(item => item.uuid !== data.needle).map(item => {
if (item.children && item.children.length) {
item.children = this.action_remove({
haystack: item.children,
needle: data.needle
})
}
return item
})
},
action_add(data) {
if (data.type=='page') {
let newitem = {
type: data.type,
id: data.id,
uuid_uri: data.uuid_uri,
target: '',
uuid: Math.random().toString(36).substring(2, 15),
children: [],
};
newitem[this.langkey('link_text')]=data[this.langkey('link_text')];
newitem[this.langkey('page_url')]=data[this.langkey('page_url')];
newitem[this.langkey('page_title')]=data[this.langkey('page_title')];
this.navigation.push(newitem);
}
else if (data.type=='custom') {
let newitem = {
type: data.type,
url: data.url,
target: data.popup ? '_blank' : data.target,
uuid: Math.random().toString(36).substring(2, 15),
children: [],
};
newitem[this.langkey('link_text')]=data[this.langkey('link_text')] ?? '';
newitem[this.langkey('link_title')]='';
this.navigation.push(newitem);
}
else {
console.warn('Invalid data.type value');
}
},
langkey(key) {
let language = this.$panel.language.code ?? 'default';
return language + '_' + key;
},
title_is_editable(item) {
if (typeof item[this.langkey('link_title')] === 'string') {
if (item[this.langkey('link_title')]!=='') {
return true;
}
}
return this.edit_title;
},
anchor_is_editable(item) {
if (typeof item[this.langkey('link_anchor')] === 'string') {
if (item[this.langkey('link_anchor')]!=='') {
return true;
}
}
return this.edit_anchor;
},
popup_is_editable(item) {
// always hide popup, if target is enabled, or target contains data
if (typeof item.target === 'string') {
if ((item.target!=='') && (item.target!=='_blank')) {
return false;
}
}
if (this.edit_target) {
return false;
}
return this.edit_popup;
},
target_is_editable(item) {
if (typeof item.target === 'string') {
if ((item.target!=='') && (item.target!=='_blank')) {
return true;
}
}
return this.edit_target;
},
class_is_editable(item) {
if (typeof item.class === 'string') {
if (item.class!=='') {
return true;
}
}
return this.edit_class;
}
},
computed: {
computed_navigation() {
return this.navigation
},
computed_levels() {
if (this.levels && parseInt(this.levels) && (parseInt(this.levels)<1)) {
return parseInt(this.levels);
}
return 10;
},
computed_breadcrumbs() {
return this.query.breadcrumbs.length >= 2 ? this.query.breadcrumbs[this.query.breadcrumbs.length - 2].id : 'site'
},
panel_content_has_diff() {
return this.$panel.content.hasDiff();
},
},
mounted() {
this.action_fetch('site');
}
}
</script>
<style lang="scss">
.navigation-field {
.k-field-depth {
text-align: right;
}
.k-field-header {
.k-dropdown-content {
margin-top: 10px;
}
.k-dropdown-item {
width: 180px;
height: auto;
padding: 8px;
margin-bottom: 8px;
.k-button-text {
opacity: 1;
white-space: normal;
text-align: left;
.k-menu-title {
opacity: 1;
width: 100%;
display: block;
margin-bottom: 8px;
}
.k-menu-subtitle {
opacity: 0.75;
font-size: .675rem;
line-height: 0.875rem;
}
}
}
}
.nestable-handle {
width: 100%;
display: block;
}
.nestable-item-content {
width: 100%;
display: flex;
flex-wrap: nowrap;
position: relative;
align-items: center;
&:hover {
.nestable-handle {
.k-button {
opacity: 1;
transition: all 0.3s ease-in-out;
}
}
}
}
.nestable-handle {
width: auto;
height: auto;
display: flex;
flex-wrap: nowrap;
position: relative;
align-items: flex-start;
margin-top: 7px;
.k-button {
opacity: 0.2;
cursor: move;
transition: all 0.3s ease-in-out;
}
}
.nestable {
position: relative;
.k-column {
margin-top: 8px;
margin-right: 8px;
}
}
.nestable .nestable-list {
margin: 0;
padding: 0 0 0 26px;
list-style-type: none;
}
.nestable > .nestable-list {
padding: 0;
}
.nestable-item:first-child,
.nestable-item-copy:first-child {
margin-top: 0;
}
.nestable-item {
position: relative;
}
.nestable-item.is-dragging .nestable-list {
pointer-events: none;
}
.nestable-item.is-dragging * {
opacity: 0;
}
.nestable-item.is-dragging:before {
content: '';
position: absolute;
top: 0;
left: 26px;
right: 0;
bottom: 0;
transition: all 0.3s ease-in-out;
}
.nestable-drag-layer {
position: fixed;
top: 0;
left: 0;
z-index: 100;
pointer-events: none;
}
.nestable-rtl .nestable-drag-layer {
left: auto;
right: 0;
}
.nestable-handle {
cursor: move;
}
}
</style>

View file

@ -0,0 +1,144 @@
<template>
<div class="k-form-input">
<div class="k-form-actions">
<slot name="handle"/>
</div>
<div class="k-form-inner">
<k-item v-if="!navigationdisabled"
v-bind:text="computed_link_text(item)"
:buttons="[
{
icon: active ? 'collapse' : (item.error ? 'question' : (item.type=='custom' ? 'pen' : 'edit')),
click: function (e) { return item_action({type: 'edit'}) }
},
]"
:options="[
{
icon: 'copy',
text: $t('editor.menu.duplicate'),
click: function (e) { return item_action({ type: 'duplicate', item: item }) }
},
{
icon: 'trash',
text: $t('editor.menu.remove'),
click: function (e) { return item_action({ type: 'remove', needle: item.uuid, haystack: navigation}) }
}
]"
/>
<k-item v-else
v-bind:text="computed_link_text(item)"
/>
<div
ref="config"
v-if="active"
class="k-form-config">
<div
ref="config"
class="k-form-group">
<slot name="dropdown_fields"/>
</div>
<div class="k-form-footer">
<span></span>
<k-button
icon="hidden"
v-on:click="item_action({ type: 'edit' })">
{{ $t('editor.menu.close') }}
</k-button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
item: Object,
fields: Object,
navigation: Array,
navigationdisabled: Boolean,
},
data() {
return {
active: false
}
},
methods: {
item_action(data) {
if (data.type === 'edit') {
this.active = !this.active
}
if (data.type === 'remove') {
this.$emit('action_remove', data)
}
if (data.type === 'duplicate') {
this.$emit('action_add', data.item)
}
},
langkey(key) {
let language = this.$panel.language.code ?? 'default';
return language + '_' + key;
},
computed_link_text(item) {
if (item.type === 'page') {
if (item[this.langkey('link_text')] === '') {
// if link text of a page is empty, use page title
return item[this.langkey('page_title')];
}
}
return item[this.langkey('link_text')];
},
},
}
</script>
<style lang="scss" scoped>
.k-form-input {
width: 100%;
display: flex;
position: relative;
margin-bottom: 2px;
.k-form-inner {
width: 100%;
display: flex;
position: relative;
flex-direction: column;
.k-form-config {
width: 100%;
border-top: 0;
display: flex;
border: 1px solid #ccc;
flex-direction: column;
margin-top: 5px;
.k-form-group {
flex-grow: 1;
padding: 1rem;
display: flex;
flex-direction: column;
}
.k-form-footer {
width: 100%;
display: flex;
margin-top: 2rem;
align-items: center;
padding: 0.6rem 1rem;
border-top: 1px solid #ccc;
justify-content: space-between;
}
}
}
.k-form-actions {
display: flex;
flex-direction: column;
.input-handle {
padding: 0 0.4rem 1rem 0.4rem;
}
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div class="k-form-input">
<div class="k-form-inner">
<slot name="text"/>
<div class="k-form-actions">
<slot name="fetch"/>
<slot name="add"/>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
item: Object,
}
}
</script>
<style lang="scss" scoped>
.k-form-input {
width: 100%;
display: flex;
position: relative;
margin-bottom: 2px;
.k-form-inner {
width: 100%;
display: flex;
position: relative;
align-items: center;
justify-content: space-between;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25rem;
padding: .5rem .75rem;
}
.k-menu-text {
overflow: hidden;
text-overflow: ellipsis;
}
.k-form-actions {
display: flex;
align-items: center;
button {
margin-left: 20px;
}
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<k-dialog
size="medium"
class="k-pages-dialog"
v-bind:visible="modal"
v-on:cancel="modal_close"
v-on:submit="modal_submit">
<slot name="modal_header"/>
<slot name="modal_body"/>
</k-dialog>
</template>
<script>
export default {
props: {
modal: Object,
},
methods: {
modal_close() {
this.$emit('modal_close')
},
modal_submit() {
this.$emit('modal_submit')
}
}
}
</script>
<style lang="scss" scoped>
.k-pages-dialog-navbar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
padding-right: 38px;
}
.k-pages-dialog-navbar .k-button {
width: 38px;
}
.k-pages-dialog-navbar .k-button[disabled] {
opacity: 0;
}
.k-pages-dialog-navbar .k-headline {
flex-grow: 1;
text-align: center;
}
.k-pages-dialog .k-list-item {
cursor: pointer;
}
.k-pages-dialog .k-list-item .k-button[data-theme="disabled"],
.k-pages-dialog .k-list-item .k-button[disabled] {
opacity: 0.25;
}
.k-pages-dialog .k-list-item .k-button[data-theme="disabled"]:hover {
opacity: 1;
}
</style>

View file

@ -0,0 +1,9 @@
import VueNestable from "vue-nestable/dist/index.esm";
import Field from './Field.vue'
panel.plugin('beluga/navigation', {
fields: {
navigation: Field
},
use: VueNestable
})

View 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.

View file

@ -0,0 +1,52 @@
![Kirby SEO Banner](/.github/new-banner.png)
<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)

View 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

View 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

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

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

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

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

View file

@ -0,0 +1,6 @@
type: seo-writer
nodes:
- seoTemplateTitle
- seoTemplateSiteTitle
toolbar:
inline: false

View 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'
]
] : [])
]
]
]
];

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

View 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'
]
] : [])
]
]
]
];

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

View file

@ -0,0 +1,141 @@
<?php
namespace tobimori\Seo\Ai;
/**
* Value object representing a streamed AI response chunk.
*/
final class Chunk
{
public const string TYPE_STREAM_START = 'stream-start';
public const string TYPE_STREAM_END = 'stream-end';
public const string TYPE_TEXT_START = 'text-start';
public const string TYPE_TEXT_DELTA = 'text-delta';
public const string TYPE_TEXT_COMPLETE = 'text-complete';
public const string TYPE_THINKING_START = 'thinking-start';
public const string TYPE_THINKING_DELTA = 'thinking-delta';
public const string TYPE_THINKING_COMPLETE = 'thinking-complete';
public const string TYPE_TOOL_CALL = 'tool-call';
public const string TYPE_TOOL_RESULT = 'tool-result';
public const string TYPE_ERROR = 'error';
private function __construct(
public readonly string $type,
public readonly mixed $payload = null,
public readonly ?string $text = null
) {
}
public static function streamStart(array $payload = []): self
{
return new self(self::TYPE_STREAM_START, $payload);
}
public static function streamEnd(array $payload = []): self
{
return new self(self::TYPE_STREAM_END, $payload);
}
public static function textStart(array $payload = []): self
{
return new self(self::TYPE_TEXT_START, $payload);
}
public static function textDelta(string $text, array $payload = []): self
{
return new self(self::TYPE_TEXT_DELTA, $payload, $text);
}
public static function textComplete(array $payload = []): self
{
return new self(self::TYPE_TEXT_COMPLETE, $payload);
}
public static function thinkingStart(array $payload = []): self
{
return new self(self::TYPE_THINKING_START, $payload);
}
public static function thinkingDelta(string $text, array $payload = []): self
{
return new self(self::TYPE_THINKING_DELTA, $payload, $text);
}
public static function thinkingComplete(array $payload = []): self
{
return new self(self::TYPE_THINKING_COMPLETE, $payload);
}
public static function toolCall(array $payload = []): self
{
return new self(self::TYPE_TOOL_CALL, $payload);
}
public static function toolResult(array $payload = []): self
{
return new self(self::TYPE_TOOL_RESULT, $payload);
}
public static function error(string $message, array $payload = []): self
{
return new self(self::TYPE_ERROR, [
'message' => $message,
'data' => $payload,
]);
}
public function isStreamStart(): bool
{
return $this->type === self::TYPE_STREAM_START;
}
public function isStreamEnd(): bool
{
return $this->type === self::TYPE_STREAM_END;
}
public function isTextStart(): bool
{
return $this->type === self::TYPE_TEXT_START;
}
public function isTextDelta(): bool
{
return $this->type === self::TYPE_TEXT_DELTA;
}
public function isTextComplete(): bool
{
return $this->type === self::TYPE_TEXT_COMPLETE;
}
public function isThinkingStart(): bool
{
return $this->type === self::TYPE_THINKING_START;
}
public function isThinkingDelta(): bool
{
return $this->type === self::TYPE_THINKING_DELTA;
}
public function isThinkingComplete(): bool
{
return $this->type === self::TYPE_THINKING_COMPLETE;
}
public function isToolCall(): bool
{
return $this->type === self::TYPE_TOOL_CALL;
}
public function isToolResult(): bool
{
return $this->type === self::TYPE_TOOL_RESULT;
}
public function isError(): bool
{
return $this->type === self::TYPE_ERROR;
}
}

View file

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

View file

@ -0,0 +1,40 @@
<?php
namespace tobimori\Seo\Ai;
use Generator;
use Kirby\Exception\InvalidArgumentException;
use tobimori\Seo\Seo;
abstract class Driver
{
public function __construct(protected string $providerId)
{
}
/**
* Streams a response for the given content.
*
* @param array<Content> $content Array of Content messages forming a conversation.
* @param string|null $model Model override.
*
* @return Generator<int, Chunk, mixed, void>
*/
abstract public function stream(array $content, string|null $model = null): Generator;
/**
* Returns a configuration value or throws when required.
*/
protected function config(string $key, mixed $default = null, bool $required = false): mixed
{
$value = Seo::option("ai.providers.{$this->providerId}.config.{$key}", $default);
if ($required === true && ($value === null || $value === '')) {
throw new InvalidArgumentException(
"Missing required \"{$key}\" configuration for driver " . static::class . '.'
);
}
return $value;
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace tobimori\Seo\Ai\Drivers;
use Generator;
use tobimori\Seo\Ai\Chunk;
use tobimori\Seo\Ai\Content;
use tobimori\Seo\Ai\Driver;
use tobimori\Seo\Ai\SseStream;
class Anthropic extends Driver
{
protected const string DEFAULT_ENDPOINT = 'https://api.anthropic.com/v1/messages';
protected const string DEFAULT_MODEL = 'claude-4-5-haiku';
/**
* @inheritDoc
*/
public function stream(
array $content,
string|null $model = null,
): Generator {
$apiKey = $this->config('apiKey', required: true);
$headers = [
'Content-Type: application/json',
'Accept: text/event-stream',
"x-api-key: {$apiKey}",
'anthropic-version: 2023-06-01',
];
$payload = [
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
'messages' => $this->buildMessages($content),
'max_tokens' => 4096,
'stream' => true,
];
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
yield from $stream->stream(function (array $event): Generator {
$type = $event['type'] ?? null;
// handle message start event
if ($type === 'message_start') {
yield Chunk::streamStart($event);
return;
}
// handle content block start (beginning of text output)
if ($type === 'content_block_start') {
$contentBlock = $event['content_block'] ?? [];
if (($contentBlock['type'] ?? null) === 'text') {
yield Chunk::textStart($event);
}
return;
}
// handle content block delta (text chunks)
if ($type === 'content_block_delta') {
$delta = $event['delta'] ?? [];
if (($delta['type'] ?? null) === 'text_delta') {
$text = $delta['text'] ?? '';
if ($text !== '') {
yield Chunk::textDelta($text, $event);
}
}
return;
}
// handle content block stop (end of text block)
if ($type === 'content_block_stop') {
yield Chunk::textComplete($event);
return;
}
// handle message stop (end of stream)
if ($type === 'message_stop') {
yield Chunk::streamEnd($event);
return;
}
// handle ping events (keep-alive)
if ($type === 'ping') {
// ignore ping events
return;
}
// handle error events
if ($type === 'error') {
$error = $event['error'] ?? [];
$message = $error['message'] ?? 'Unknown Anthropic streaming error.';
yield Chunk::error($message, $event);
return;
}
// handle message delta (contains usage info)
if ($type === 'message_delta') {
// we could extract usage info here if needed
return;
}
});
}
/**
* Translates an array of Content messages into the Anthropic messages format.
*
* @param array<Content> $content
*/
private function buildMessages(array $content): array
{
$messages = [];
foreach ($content as $message) {
$blocks = [];
foreach ($message->blocks() as $block) {
if ($block['type'] === 'image') {
$blocks[] = [
'type' => 'image',
'source' => [
'type' => 'base64',
'media_type' => $block['mediaType'],
'data' => $block['data'],
],
];
} elseif ($block['type'] === 'text') {
$blocks[] = [
'type' => 'text',
'text' => $block['text'],
];
}
}
$messages[] = [
'role' => $message->role(),
'content' => $blocks,
];
}
return $messages;
}
}

View file

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

View file

@ -0,0 +1,118 @@
<?php
namespace tobimori\Seo\Ai\Drivers;
use Generator;
use tobimori\Seo\Ai\Chunk;
use tobimori\Seo\Ai\Content;
use tobimori\Seo\Ai\Driver;
use tobimori\Seo\Ai\SseStream;
class OpenAi extends Driver
{
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/responses';
protected const string DEFAULT_MODEL = 'gpt-5-mini-2025-08-07';
/**
* @inheritDoc
*/
public function stream(
array $content,
string|null $model = null,
): Generator {
$apiKey = $this->config('apiKey', required: true);
$headers = [
'Content-Type: application/json',
'Accept: text/event-stream',
"Authorization: Bearer {$apiKey}",
];
if ($organization = $this->config('organization')) {
$headers[] = "OpenAI-Organization: {$organization}";
}
$payload = [
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
'input' => $this->buildInput($content),
// instructions does not work for e.g. openrouter so let's just put everything in user prompt
'stream' => true,
];
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
yield from $stream->stream(function (array $event): Generator {
$type = $event['type'] ?? null;
if ($type === 'response.created') {
yield Chunk::streamStart($event);
return;
}
if ($type === 'response.in_progress') {
yield Chunk::textStart($event);
return;
}
if ($type === 'response.output_text.delta') {
$delta = $event['delta'] ?? '';
if ($delta !== '') {
yield Chunk::textDelta($delta, $event);
}
return;
}
if ($type === 'response.output_text.done') {
yield Chunk::textComplete($event);
return;
}
if ($type === 'response.completed') {
yield Chunk::streamEnd($event);
return;
}
if ($type === 'response.output_item.added' && ($event['item']['type'] ?? null) === 'reasoning') {
yield Chunk::thinkingStart($event);
return;
}
if ($type === 'response.error') {
$message = $event['error']['message'] ?? 'Unknown OpenAI streaming error.';
yield Chunk::error($message, $event);
}
});
}
/**
* Translates an array of Content messages into the OpenAI Responses API input format.
*
* @param array<Content> $content
*/
private function buildInput(array $content): array
{
$input = [];
foreach ($content as $message) {
$blocks = [];
foreach ($message->blocks() as $block) {
if ($block['type'] === 'image') {
$blocks[] = [
'type' => 'input_image',
'image_url' => "data:{$block['mediaType']};base64,{$block['data']}",
];
} elseif ($block['type'] === 'text') {
$blocks[] = [
'type' => 'input_text',
'text' => $block['text'],
];
}
}
$input[] = [
'role' => $message->role(),
'content' => $blocks,
];
}
return $input;
}
}

View file

@ -0,0 +1,169 @@
<?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;
use function is_array;
use function is_string;
/**
* Driver for the OpenAI Chat Completions API (legacy) and any OpenAI-compatible
* endpoint exposing `/chat/completions`, such as Cloudflare AI Gateway, OpenRouter,
* Groq, Together, or self-hosted compat servers.
*/
class OpenAiCompletions extends Driver
{
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
protected const string DEFAULT_MODEL = 'gpt-5.4-nano';
/**
* @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}";
}
foreach ((array)$this->config('headers', []) as $name => $value) {
$headers[] = "{$name}: {$value}";
}
$payload = [
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
'messages' => $this->buildMessages($content),
'stream' => true,
];
if ($maxTokens = $this->config('maxTokens')) {
$payload['max_tokens'] = (int)$maxTokens;
}
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
$started = false;
$textStarted = false;
yield from $stream->stream(function (array $event) use (&$started, &$textStarted): Generator {
if (isset($event['error'])) {
$error = $event['error'];
$message = is_array($error) ? ($error['message'] ?? 'Unknown Chat Completions streaming error.') : (string)$error;
yield Chunk::error($message, $event);
return;
}
$choice = $event['choices'][0] ?? null;
if ($choice === null) {
return;
}
if (!$started) {
yield Chunk::streamStart($event);
$started = true;
}
$delta = $choice['delta'] ?? [];
$text = $delta['content'] ?? null;
if (is_string($text) && $text !== '') {
if (!$textStarted) {
yield Chunk::textStart($event);
$textStarted = true;
}
yield Chunk::textDelta($text, $event);
}
$finishReason = $choice['finish_reason'] ?? null;
if ($finishReason !== null) {
if ($finishReason === 'content_filter') {
yield Chunk::error('Response blocked by content filter.', $event);
return;
}
if ($textStarted) {
yield Chunk::textComplete($event);
}
yield Chunk::streamEnd($event);
}
});
}
/**
* Translates an array of Content messages into the Chat Completions messages format.
* Text-only messages use the legacy string `content` shape for broader compat; messages
* with images use the multi-modal parts array.
*
* @param array<Content> $content
*/
private function buildMessages(array $content): array
{
$messages = [];
foreach ($content as $message) {
$blocks = $message->blocks();
$hasImage = false;
foreach ($blocks as $block) {
if ($block['type'] === 'image') {
$hasImage = true;
break;
}
}
if (!$hasImage) {
$text = '';
foreach ($blocks as $block) {
if ($block['type'] === 'text') {
$text .= $block['text'];
}
}
$messages[] = [
'role' => $message->role(),
'content' => $text,
];
continue;
}
$parts = [];
foreach ($blocks as $block) {
if ($block['type'] === 'image') {
$parts[] = [
'type' => 'image_url',
'image_url' => [
'url' => "data:{$block['mediaType']};base64,{$block['data']}",
],
];
} elseif ($block['type'] === 'text') {
$parts[] = [
'type' => 'text',
'text' => $block['text'],
];
}
}
$messages[] = [
'role' => $message->role(),
'content' => $parts,
];
}
return $messages;
}
}

View file

@ -0,0 +1,194 @@
<?php
namespace tobimori\Seo\Ai;
use Generator;
use Kirby\Exception\Exception as KirbyException;
use function curl_errno;
use function curl_error;
use function curl_getinfo;
use function curl_init;
use function curl_multi_add_handle;
use function curl_multi_close;
use function curl_multi_exec;
use function curl_multi_init;
use function curl_multi_remove_handle;
use function curl_multi_select;
use function curl_setopt_array;
use function strlen;
use function sprintf;
use function is_array;
use const CURLOPT_HTTPHEADER;
use const CURLOPT_POST;
use const CURLOPT_POSTFIELDS;
use const CURLOPT_RETURNTRANSFER;
use const CURLOPT_TIMEOUT;
use const CURLOPT_WRITEFUNCTION;
use const CURLINFO_HTTP_CODE;
use const CURLM_CALL_MULTI_PERFORM;
final class SseStream
{
private const int ERROR_CONTEXT_LIMIT = 8192;
public function __construct(
private readonly string $endpoint,
private readonly array $headers,
private readonly array $payload,
private readonly int $timeout = 120
) {
}
/**
* @param callable(array $event): Generator<Chunk> $mapper
* @return Generator<Chunk>
*/
public function stream(callable $mapper): Generator
{
$buffer = '';
$response = '';
$handle = curl_init($this->endpoint);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_POSTFIELDS => json_encode(
$this->payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_WRITEFUNCTION => static function ($curl, $data) use (&$buffer, &$response) {
$buffer .= $data;
$currentLength = strlen($response);
if ($currentLength < self::ERROR_CONTEXT_LIMIT) {
$response .= substr($data, 0, self::ERROR_CONTEXT_LIMIT - $currentLength);
}
return strlen($data);
},
]);
$multi = curl_multi_init();
curl_multi_add_handle($multi, $handle);
try {
$running = null;
do {
$status = curl_multi_exec($multi, $running);
if ($status === CURLM_CALL_MULTI_PERFORM) {
continue;
}
yield from $this->drainBuffer($buffer, $mapper);
if ($running) {
curl_multi_select($multi, 0.1);
}
} while ($running);
yield from $this->drainBuffer($buffer, $mapper, true);
$errno = curl_errno($handle);
if ($errno) {
throw new KirbyException(curl_error($handle) ?: 'Streaming request failed.', $errno);
}
$code = curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($code !== null && $code >= 400) {
$message = sprintf('Streaming request failed (%d)', $code);
$body = trim($response);
if ($body !== '') {
$decoded = json_decode($body, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$body = $decoded['error']['message'] ?? $decoded['message'] ?? $body;
}
if (strlen($body) > 200) {
$body = substr($body, 0, 200) . '...';
}
$message .= ': ' . preg_replace('/\s+/', ' ', $body);
}
throw new KirbyException($message);
}
} finally {
curl_multi_remove_handle($multi, $handle);
curl_multi_close($multi);
}
}
/**
* @param callable(array $event): Generator<Chunk> $mapper
* @return Generator<Chunk>
*/
private function drainBuffer(string &$buffer, callable $mapper, bool $final = false): Generator
{
while (
preg_match('/\r?\n\r?\n/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1
) {
$pos = $match[0][1];
$len = strlen($match[0][0]);
$frame = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + $len);
foreach ($this->mapFrame($frame, $mapper) as $chunk) {
yield $chunk;
}
}
if ($final && trim($buffer) !== '') {
foreach ($this->mapFrame($buffer, $mapper) as $chunk) {
yield $chunk;
}
$buffer = '';
}
}
/**
* @param callable(array $event): Generator<Chunk> $mapper
* @return Generator<Chunk>
*/
private function mapFrame(string $frame, callable $mapper): Generator
{
$frame = trim($frame);
if ($frame === '') {
return;
}
$payload = '';
foreach (preg_split("/\r\n|\n|\r/", $frame) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, ':')) {
continue;
}
if (str_starts_with($line, 'data:')) {
$payload .= substr($line, 5);
}
}
$payload = trim($payload);
if ($payload === '' || $payload === '[DONE]') {
return;
}
$event = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($event)) {
return;
}
yield from $mapper($event);
}
}

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

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

View file

@ -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')
);
}
}

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

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

Some files were not shown because too many files have changed in this diff Show more