Set up Kirby site structure, plugins and blueprints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37eb4e2183
commit
64ea6acce8
216 changed files with 12470 additions and 7 deletions
|
|
@ -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
186
composer.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
5
content/archives/archives.txt
Normal file
5
content/archives/archives.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Archives
|
||||
|
||||
----
|
||||
|
||||
Uuid: txrmlqm6zlaxszpv
|
||||
5
content/archives/by-year/by-year.txt
Normal file
5
content/archives/by-year/by-year.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Par année universitaire
|
||||
|
||||
----
|
||||
|
||||
Uuid: dthnb8i5jun6udbd
|
||||
5
content/career/1_alumni/alumni.txt
Normal file
5
content/career/1_alumni/alumni.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Alumni · Galerie des diplômés
|
||||
|
||||
----
|
||||
|
||||
Uuid: uslnjr1kcrh0fksh
|
||||
5
content/career/2_resources/resources.txt
Normal file
5
content/career/2_resources/resources.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Ressources
|
||||
|
||||
----
|
||||
|
||||
Uuid: mtho0strg9viiuxh
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Title: VAE · Formation continue
|
||||
|
||||
----
|
||||
|
||||
Uuid: 3ke7tgzd4zoywg1f
|
||||
5
content/career/career.txt
Normal file
5
content/career/career.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Insertion professionnelle
|
||||
|
||||
----
|
||||
|
||||
Uuid: nnt37rdwbcxzvqbs
|
||||
5
content/higher-education/1_cursus-art/cursus-art.txt
Normal file
5
content/higher-education/1_cursus-art/cursus-art.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Art · DNA · DNSEP
|
||||
|
||||
----
|
||||
|
||||
Uuid: ct3w51s4y6c2a1oo
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Title: Design · DNA · DNSEP
|
||||
|
||||
----
|
||||
|
||||
Uuid: vezdljcloi7uqri1
|
||||
5
content/higher-education/3_admissions/admissions.txt
Normal file
5
content/higher-education/3_admissions/admissions.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Inscriptions / concours
|
||||
|
||||
----
|
||||
|
||||
Uuid: 3xi7toojd2vk7wbj
|
||||
5
content/higher-education/higher-education.txt
Normal file
5
content/higher-education/higher-education.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Enseignement supérieur
|
||||
|
||||
----
|
||||
|
||||
Uuid: 2jl6go9jucceh4ae
|
||||
5
content/home/2_agenda/agenda.txt
Normal file
5
content/home/2_agenda/agenda.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Agenda
|
||||
|
||||
----
|
||||
|
||||
Uuid: j6tdqcfadwdpuwzx
|
||||
5
content/home/3_starred-events/starred-events.txt
Normal file
5
content/home/3_starred-events/starred-events.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Événements phares
|
||||
|
||||
----
|
||||
|
||||
Uuid: 5bflrej3tqifinte
|
||||
5
content/international/international.txt
Normal file
5
content/international/international.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: International
|
||||
|
||||
----
|
||||
|
||||
Uuid: ulmylokycamwwg5f
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
|
|
@ -0,0 +1 @@
|
|||
Uuid: hjm3ouicarfkieqx
|
||||
85
content/news/1_jean-marc-bullet-designer/news-item.txt
Normal file
85
content/news/1_jean-marc-bullet-designer/news-item.txt
Normal 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 à l’intersection de l’innovation sociale et de la préservation de l’environnement. Diplômé de la Haute École des Arts du Rhin (HEAR) de Strasbourg en 2005, puis de l’ENSCI-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 d’un 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 l’a mené en Chine, où il a travaillé dans les industries du marbre et de l’énergie solaire pour réduire l’empreinte é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 Presqu’une î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 l’histoire 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 l’influence de l’histoire 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 l’accès de la diaspora antillaise à son matrimoine culturel en recréant le rituel caribéen de l’infusion. 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
5
content/news/news.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Actualités
|
||||
|
||||
----
|
||||
|
||||
Uuid: 7zubbxe67qf7w2ml
|
||||
5
content/presentation/presentation.txt
Normal file
5
content/presentation/presentation.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Présentation générale
|
||||
|
||||
----
|
||||
|
||||
Uuid: duykm3jihethqebg
|
||||
3
content/public-courses/1_programs/programs.txt
Normal file
3
content/public-courses/1_programs/programs.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Title: Offre pédagogique
|
||||
|
||||
----
|
||||
3
content/public-courses/2_registration/registration.txt
Normal file
3
content/public-courses/2_registration/registration.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Title: Inscriptions
|
||||
|
||||
----
|
||||
5
content/public-courses/public-courses.txt
Normal file
5
content/public-courses/public-courses.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: Cours publics
|
||||
|
||||
----
|
||||
|
||||
Uuid: lpe5tjcw2qdrzctg
|
||||
139
content/site.txt
139
content/site.txt
|
|
@ -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:
|
||||
7
site/blueprints/pages/admissions.yml
Normal file
7
site/blueprints/pages/admissions.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Inscriptions / concours
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/agenda.yml
Normal file
7
site/blueprints/pages/agenda.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Agenda
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/alumni.yml
Normal file
7
site/blueprints/pages/alumni.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Alumni · Galerie des diplômés
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/archive-year.yml
Normal file
7
site/blueprints/pages/archive-year.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Année universitaire
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/archives.yml
Normal file
7
site/blueprints/pages/archives.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Archives
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/career.yml
Normal file
7
site/blueprints/pages/career.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Insertion professionnelle
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/continuing-education.yml
Normal file
7
site/blueprints/pages/continuing-education.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: VAE · Formation continue
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/cursus-art.yml
Normal file
7
site/blueprints/pages/cursus-art.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Art · DNA · DNSEP
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/cursus-design.yml
Normal file
7
site/blueprints/pages/cursus-design.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Design · DNA · DNSEP
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
|
|
@ -19,3 +19,4 @@ columns:
|
|||
files:
|
||||
type: files
|
||||
|
||||
seo: seo/page
|
||||
|
|
|
|||
7
site/blueprints/pages/higher-education.yml
Normal file
7
site/blueprints/pages/higher-education.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Enseignement supérieur
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/home.yml
Normal file
7
site/blueprints/pages/home.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Accueil
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/international.yml
Normal file
7
site/blueprints/pages/international.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: International
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
67
site/blueprints/pages/news-item.yml
Normal file
67
site/blueprints/pages/news-item.yml
Normal 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
|
||||
18
site/blueprints/pages/news.yml
Normal file
18
site/blueprints/pages/news.yml
Normal 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
|
||||
7
site/blueprints/pages/presentation.yml
Normal file
7
site/blueprints/pages/presentation.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Présentation générale
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/programs.yml
Normal file
7
site/blueprints/pages/programs.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Offre pédagogique
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/public-courses.yml
Normal file
7
site/blueprints/pages/public-courses.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Cours publics
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/registration.yml
Normal file
7
site/blueprints/pages/registration.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Inscriptions
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/resources.yml
Normal file
7
site/blueprints/pages/resources.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Ressources
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
7
site/blueprints/pages/starred-events.yml
Normal file
7
site/blueprints/pages/starred-events.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
title: Événements phares
|
||||
|
||||
tabs:
|
||||
content:
|
||||
label: contenu
|
||||
files: tabs/files
|
||||
seo: seo/page
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
site/blueprints/tabs/files.yml
Normal file
17
site/blueprints/tabs/files.yml
Normal 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
22
site/config/config.php
Normal 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
30
site/config/menu.php
Normal 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
2
site/logs/promote.log
Normal 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
|
||||
6
site/plugins/kirby-navigation/.gitignore
vendored
Normal file
6
site/plugins/kirby-navigation/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
composer.lock
|
||||
package-lock.json
|
||||
.idea
|
||||
.cache
|
||||
.DS_Store
|
||||
node_modules
|
||||
177
site/plugins/kirby-navigation/README.md
Normal file
177
site/plugins/kirby-navigation/README.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Kirby Navigation Field
|
||||
|
||||
A Navigation field for [Kirby CMS](https://getkirby.com).
|
||||
|
||||
## Preview
|
||||
|
||||

|
||||
|
||||
## 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/)
|
||||
49
site/plugins/kirby-navigation/composer.json
Normal file
49
site/plugins/kirby-navigation/composer.json
Normal 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"
|
||||
}
|
||||
}
|
||||
83
site/plugins/kirby-navigation/config/api.php
Normal file
83
site/plugins/kirby-navigation/config/api.php
Normal 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,
|
||||
];
|
||||
}
|
||||
],
|
||||
];
|
||||
};
|
||||
123
site/plugins/kirby-navigation/config/methods.php
Normal file
123
site/plugins/kirby-navigation/config/methods.php
Normal 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();
|
||||
},
|
||||
];
|
||||
60
site/plugins/kirby-navigation/config/props.php
Normal file
60
site/plugins/kirby-navigation/config/props.php
Normal 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);
|
||||
}
|
||||
];
|
||||
78
site/plugins/kirby-navigation/config/save.php
Normal file
78
site/plugins/kirby-navigation/config/save.php
Normal 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;
|
||||
};
|
||||
252
site/plugins/kirby-navigation/includes/refresh_item.inc.php
Normal file
252
site/plugins/kirby-navigation/includes/refresh_item.inc.php
Normal 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;
|
||||
};
|
||||
|
||||
1
site/plugins/kirby-navigation/index.css
Normal file
1
site/plugins/kirby-navigation/index.css
Normal 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}
|
||||
6
site/plugins/kirby-navigation/index.js
Normal file
6
site/plugins/kirby-navigation/index.js
Normal file
File diff suppressed because one or more lines are too long
29
site/plugins/kirby-navigation/index.php
Normal file
29
site/plugins/kirby-navigation/index.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
26
site/plugins/kirby-navigation/languages/de.php
Normal file
26
site/plugins/kirby-navigation/languages/de.php
Normal 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'
|
||||
];
|
||||
26
site/plugins/kirby-navigation/languages/en.php
Normal file
26
site/plugins/kirby-navigation/languages/en.php
Normal 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'
|
||||
];
|
||||
26
site/plugins/kirby-navigation/languages/fr.php
Normal file
26
site/plugins/kirby-navigation/languages/fr.php
Normal 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'
|
||||
];
|
||||
26
site/plugins/kirby-navigation/languages/tr.php
Normal file
26
site/plugins/kirby-navigation/languages/tr.php
Normal 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'
|
||||
];
|
||||
BIN
site/plugins/kirby-navigation/navigation-demo-1.gif
Normal file
BIN
site/plugins/kirby-navigation/navigation-demo-1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
13
site/plugins/kirby-navigation/package.json
Normal file
13
site/plugins/kirby-navigation/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
site/plugins/kirby-navigation/snippets/navigation.php
Normal file
34
site/plugins/kirby-navigation/snippets/navigation.php
Normal 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>
|
||||
32
site/plugins/kirby-navigation/snippets/navigation_item.php
Normal file
32
site/plugins/kirby-navigation/snippets/navigation_item.php
Normal 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 ?? []));
|
||||
|
||||
628
site/plugins/kirby-navigation/src/Field.vue
Normal file
628
site/plugins/kirby-navigation/src/Field.vue
Normal 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>
|
||||
144
site/plugins/kirby-navigation/src/components/Lists/Default.vue
Normal file
144
site/plugins/kirby-navigation/src/components/Lists/Default.vue
Normal 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>
|
||||
55
site/plugins/kirby-navigation/src/components/Lists/Modal.vue
Normal file
55
site/plugins/kirby-navigation/src/components/Lists/Modal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
9
site/plugins/kirby-navigation/src/index.js
Normal file
9
site/plugins/kirby-navigation/src/index.js
Normal 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
|
||||
})
|
||||
132
site/plugins/kirby-seo/LICENSE.md
Normal file
132
site/plugins/kirby-seo/LICENSE.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Plugin License Agreement
|
||||
|
||||
Source: https://plugins.andkindness.com/license-agreement
|
||||
|
||||
While most of our plugins source code are publicly available, they are, unless specified otherwise, not free software. To use any plugin in production, you need to purchase a license.
|
||||
|
||||
## Summary
|
||||
|
||||
> This is a legally non-binding summary. Please review the full license text carefully before using the plugins.
|
||||
|
||||
This is a legal agreement between you (the customer) and Love & Kindness GmbH for using their Kirby CMS plugins. By downloading or using the plugins, or by purchasing a license, you agree to these terms:
|
||||
|
||||
### What You Can Do
|
||||
|
||||
- **Install and use the plugin on one website or multi-language website per license purchase**
|
||||
If you need a separate Kirby CMS license for your site, you'll most likely also need a separate plugin license
|
||||
- **Make copies of the plugin for backup or development purposes**
|
||||
- **Modify the source code for your own use**
|
||||
|
||||
### What You Cannot Do
|
||||
|
||||
- **Use the plugin on additional websites without buying additional licenses**
|
||||
- **Redistribute or resell the plugin or your modified versions**
|
||||
|
||||
### Support & Updates
|
||||
|
||||
- **Free updates for minor/patch releases, paid upgrades for major releases possible**
|
||||
- **Email support provided for active license holders**
|
||||
|
||||
---
|
||||
|
||||
This license is a legal agreement between **You** and **Love & Kindness GmbH, Beimoorstr. 20, 22081 Hamburg, Germany** (therein "**Our**"/"**We**"/"**Us**") for the use of any Kirby CMS plugins and resources (the "**Plugin**") created by Us and sold via Paddle.com. By downloading any Plugin files or resources or purchasing a license to the Plugin, you agree to be bound by the terms and conditions of this license.
|
||||
|
||||
## Permitted Use
|
||||
|
||||
This agreement grants a license for each purchase to install and use a single instance of the Plugin on a **specific website** limited by **its domain & subdomain**. If You use the cross-domain multi-language feature with the same `content` folder, these domains count as the same Website.
|
||||
|
||||
Additional Plugin licenses must be purchased in order to install and use the Plugin on **additional websites**.
|
||||
|
||||
The license is **non-exclusive** and **generally non-transferable**.
|
||||
|
||||
A license is valid for all minor & patch updates of the Plugin (e.g. 1.0.x to 1.1.x). We reserve the right to charge an **upgrade fee for major updates** (e.g. 1.x.x to 2.x.x). Whether a release is a patch, minor, or major release is at Our sole discretion.
|
||||
|
||||
## Development Usage
|
||||
|
||||
You are permitted to install and use the Plugin on a personal computer (such as a desktop PC, notebook, or tablet) or a server, free of charge, for as long as necessary during the development stage.
|
||||
|
||||
Any website that is used **purely for the purposes of development and client preview** is considered development usage. It must only be accessible by a restricted number of users. A website with **the intention to handle production data** is never considered development usage, no matter if the related website is publicly accessible or not.
|
||||
|
||||
## Refund Policy
|
||||
|
||||
We offer refunds on the Plugin within **14 days of purchase**. Contact support@andkindness.com for assistance.
|
||||
|
||||
## Technical Support
|
||||
|
||||
Technical support is available via email for active license owners.
|
||||
|
||||
No support is provided for free plugins or plugins that are available both for free and paid usage. We do not provide phone support. No representations or guarantees are made regarding the response time in which support questions are answered, but we will do our very best to respond quickly.
|
||||
|
||||
## All Rights Reserved
|
||||
|
||||
Love & Kindness GmbH **owns all rights**, title and interest to the Plugin (including all intellectual property rights) and **reserves all rights to the Plugin** that are not expressly granted in this Agreement.
|
||||
|
||||
## Restrictions
|
||||
|
||||
### Making Copies
|
||||
|
||||
You may make **copies of the Plugin** in any machine readable form solely for purposes of **deploying a website to a server, developing a website on a personal computer or server or as a backup**, provided that You reproduce the Plugin in its original form and with all proprietary notices on the copy.
|
||||
|
||||
You may not reproduce the Plugin or its source code, in whole or in part, for **any other purpose**.
|
||||
|
||||
### Modification of the Source Code
|
||||
|
||||
You may **alter, modify or extend the source code** for Your own use. You may also **commission a third party** to perform those modifications for You.
|
||||
|
||||
However You may not **alter or circumvent the licensing features**, including (but not limited to) the license validation or **resell, redistribute or transfer** the modified or derivative version.
|
||||
|
||||
### Ownership and Intellectual Property
|
||||
|
||||
The Plugin is copyrighted by Us. All rights not expressly granted to You are retained by Us, including intellectual property rights.
|
||||
|
||||
### Disclaimer of Warranty
|
||||
|
||||
THE PLUGIN IS OFFERED ON AN **"AS-IS" BASIS** AND **NO WARRANTY**, EITHER EXPRESSED OR IMPLIED, IS GIVEN. WE EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. YOU ASSUME ALL RISK ASSOCIATED WITH THE QUALITY, PERFORMANCE, INSTALLATION AND USE OF THE PLUGIN INCLUDING, BUT NOT LIMITED TO, THE RISKS OF PROGRAM ERRORS, DAMAGE TO EQUIPMENT, LOSS OF DATA OR SOFTWARE PROGRAMS, OR UNAVAILABILITY OR INTERRUPTION OF OPERATIONS. **YOU ARE SOLELY RESPONSIBLE** FOR DETERMINING THE APPROPRIATENESS OF USE OF THE PLUGIN AND ASSUME ALL RISKS ASSOCIATED WITH ITS USE. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE THE PLUGIN WHILE SOMEONE ELSE IS THE LICENSEE).
|
||||
|
||||
### Limitations of Liability
|
||||
|
||||
YOU EXPRESSLY UNDERSTAND AND AGREE THAT **WE SHALL NOT BE LIABLE** FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, GOODWILL, USE, DATA OR OTHER INTANGIBLE LOSSES (EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES). SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF THE LIMITATION OR EXCLUSION OF LIABILITY FOR INCIDENTAL OR CONSEQUENTIAL DAMAGES. ACCORDINGLY, **SOME OF THE ABOVE LIMITATIONS MAY NOT APPLY TO YOU**. **IN NO EVENT WILL OUR TOTAL CUMULATIVE DAMAGES EXCEED** THE FEES YOU PAID TO US UNDER THIS AGREEMENT IN THE MOST RECENT TWELVE-MONTH PERIOD. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE THE PLUGIN WHILE SOMEONE ELSE IS THE LICENSEE).
|
||||
|
||||
## Termination
|
||||
|
||||
The License may be terminated by either party if terms are breached and not remedied within a specified period.
|
||||
|
||||
## Governing Law
|
||||
|
||||
Any legal disputes that arise from or relate to this Agreement shall be exclusively resolved in the courts located in Hamburg, Germany. Nonetheless, we reserve the right to initiate legal proceedings against you in the jurisdiction where your principal place of business is situated.
|
||||
|
||||
Should you be domiciled in Germany, the stipulations of the first paragraph will be relevant only if you are a businessperson, a public law entity, or a special fund under public law.
|
||||
|
||||
If your domicile is not in Germany but within another member state of the European Union, the provisions of the first paragraph will apply to you only if you do not qualify as a consumer as defined under Article 17 of Regulation (EU) No. 1215/2012. In such circumstances, you may bring a legal action against us either in the jurisdiction of our place of business or where you habitually reside. Conversely, we are entitled to sue you exclusively in the courts of the member state where you have your domicile.
|
||||
|
||||
In the event that your residence is outside Germany and not within any European Union member state, the provisions of the first paragraph are fully applicable without any modification.
|
||||
|
||||
## Severability Clause
|
||||
|
||||
Should any provision of this Agreement be or become invalid, void or unenforceable, in whole or in part, at present or in the future, this shall not affect the validity of the remaining provisions of this Agreement. The same shall apply if a gap requiring supplementation arises after conclusion of this Agreement. The parties shall replace the invalid, void or unenforceable provision or gap requiring filling by a valid provision which in its legal or economic content takes account of the invalid, void provision and the overall content of the agreement. § 139 BGB (partial invalidity) is expressly waived.
|
||||
|
||||
---
|
||||
|
||||
Kirby SEO 1.x releases were previously licensed under the MIT License.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2024 Tobias Möritz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
52
site/plugins/kirby-seo/README.md
Normal file
52
site/plugins/kirby-seo/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||

|
||||
|
||||
<h1 align="center">Kirby SEO</h1>
|
||||
<p align="center">
|
||||
The default choice for SEO on Kirby: Implement technical SEO & Meta best practices with ease and provide an easy-to-use editor experience
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🔎 All-in-one SEO and meta solution
|
||||
- 🪜 The Meta Cascade: Intelligently merge metadata from multiple sources
|
||||
- 🎛 Completely configurable: Disable features you don't need
|
||||
- 💻 Simple Panel UI with previews for Google, Twitter, Facebook & Co.
|
||||
- 📮 [Schema.org (JSON-LD)](https://schema.org/) support with fluent classes
|
||||
- 🤖 Automatic Robots rule generation, based on page status
|
||||
- 📝 Sitemap generation with multi-lang support
|
||||
|
||||
### New in Version 2
|
||||
|
||||
- 🚀 Kirby 5 support
|
||||
- 💻 Even better and easier Panel UI
|
||||
- ✨ AI assist for writing meta tags
|
||||
- 🔘 IndexNow support
|
||||
|
||||
## Get started
|
||||
|
||||
[Read the documentation](https://plugins.andkindness.com/seo/docs/get-started/feature-overview) to get started with Kirby SEO.
|
||||
|
||||
If you're looking to use Kirby SEO with Kirby 5 or newer, please install the Beta version of the plugin:
|
||||
|
||||
`composer require tobimori/kirby-seo:^2.0.0-beta.2`
|
||||
|
||||
### What does Beta mean for Kirby SEO 2?
|
||||
|
||||
The core features of Kirby SEO, such as the meta cascade, the panel setup, sitemap and robots are stable and can be used in production. New features of v2 might be unstable or can occur breaking changes until the final release.
|
||||
|
||||
## Contributing
|
||||
|
||||
Kirby SEO is open to contributors: If you open a pull request that gets merged, such as fixing a bug or translating the plugin into a new language, you're eligible for a free SEO license of your choice. Please note that I might reject minor repeat contributions or simple fixes of typos for this. Please send an email to support after your contribution has been merged.
|
||||
|
||||
## License
|
||||
|
||||
Kirby SEO 2.0 is not free software. In order to run it on a public server, you'll have to purchase a valid Kirby license & a valid SEO license.
|
||||
**The plugin is currently free to use while in pre-release state.** You can [pre-order a license](https://plugins.andkindness.com/seo/preorder) with a 20% discount for a limited time.
|
||||
|
||||
Copyright 2023-2025 © Tobias Möritz - Love & Kindness GmbH
|
||||
|
||||
---
|
||||
|
||||
[Kirby SEO 1.0 is licensed under the MIT license.](./LICENSE.md)
|
||||
34
site/plugins/kirby-seo/blueprints/fields/meta-group.yml
Normal file
34
site/plugins/kirby-seo/blueprints/fields/meta-group.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type: group
|
||||
fields:
|
||||
_metaHeadline:
|
||||
label: seo.page.meta.headline
|
||||
type: headline
|
||||
numbered: false
|
||||
metaTitle:
|
||||
label: seo.fields.titleOverwrite.label
|
||||
type: seo-writer
|
||||
ai: title
|
||||
placeholder: "{{ page.title }}"
|
||||
metaTemplate:
|
||||
extends: seo/fields/title-template
|
||||
label: seo.fields.metaTitleTemplate.label
|
||||
help: seo.fields.metaTitleTemplate.help
|
||||
width: 2/3
|
||||
placeholder: "{{ page.metadata.metaTemplate }}"
|
||||
useTitleTemplate:
|
||||
label: seo.fields.useTitleTemplate.label
|
||||
type: toggle
|
||||
help: seo.fields.useTitleTemplate.help
|
||||
width: 1/3
|
||||
default: true
|
||||
text:
|
||||
- "{{ t('seo.fields.useTitleTemplate.no') }}"
|
||||
- "{{ t('seo.fields.useTitleTemplate.yes') }}"
|
||||
metaDescription:
|
||||
label: seo.fields.metaDescription.label
|
||||
type: seo-writer
|
||||
ai: description
|
||||
help: seo.fields.metaDescription.help
|
||||
placeholder: "{{ page.metadata.metaDescription }}"
|
||||
_seoLine1:
|
||||
type: line
|
||||
42
site/plugins/kirby-seo/blueprints/fields/og-group.yml
Normal file
42
site/plugins/kirby-seo/blueprints/fields/og-group.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
type: group
|
||||
fields:
|
||||
_ogHeadline:
|
||||
label: seo.page.og.headline
|
||||
type: headline
|
||||
numbered: false
|
||||
help: seo.site.og.headline.help
|
||||
ogTemplate:
|
||||
extends: seo/fields/title-template
|
||||
label: seo.fields.ogTitleTemplate.label
|
||||
width: 2/3
|
||||
help: seo.fields.metaTitleTemplate.help
|
||||
placeholder: "{{ page.metadata.ogTemplate }}"
|
||||
useOgTemplate:
|
||||
label: seo.fields.useTitleTemplate.label
|
||||
type: toggle
|
||||
help: seo.fields.useTitleTemplate.help
|
||||
width: 1/3
|
||||
default: true
|
||||
text:
|
||||
- "{{ t('seo.fields.useTitleTemplate.no') }}"
|
||||
- "{{ t('seo.fields.useTitleTemplate.yes') }}"
|
||||
ogDescription:
|
||||
label: seo.fields.ogDescription.label
|
||||
type: seo-writer
|
||||
ai: og-description
|
||||
placeholder: "{{ page.metadata.ogDescription }}"
|
||||
ogImage:
|
||||
label: seo.fields.ogImage.label
|
||||
extends: seo/fields/og-image
|
||||
empty: seo.fields.ogImage.empty
|
||||
cropOgImage:
|
||||
label: seo.fields.cropOgImage.label
|
||||
type: select
|
||||
width: 1/1
|
||||
placeholder: "{{ t('seo.common.default') }} {{ site.cropOgImage.toBool ? t('seo.common.yes') : t('seo.common.no') }}"
|
||||
options:
|
||||
"true": "{{ t('seo.common.yes') }}"
|
||||
"false": "{{ t('seo.common.no') }}"
|
||||
help: seo.fields.cropOgImage.help
|
||||
_seoLine2:
|
||||
type: line
|
||||
31
site/plugins/kirby-seo/blueprints/fields/og-image.php
Normal file
31
site/plugins/kirby-seo/blueprints/fields/og-image.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
return function (App $kirby) {
|
||||
$blueprint = [
|
||||
'type' => 'files',
|
||||
'multiple' => false,
|
||||
'uploads' => [],
|
||||
'query' => 'model.images'
|
||||
];
|
||||
|
||||
if ($parent = option('tobimori.seo.files.parent')) {
|
||||
$blueprint['uploads'] = [
|
||||
'parent' => $parent
|
||||
];
|
||||
$blueprint['query'] = "{$parent}.images";
|
||||
}
|
||||
|
||||
if ($template = option('tobimori.seo.files.template')) {
|
||||
$blueprint['uploads'] = [
|
||||
...$blueprint['uploads'],
|
||||
'template' => $template
|
||||
];
|
||||
|
||||
$blueprint['query'] = "{$blueprint['query']}.filterBy('template', '{$template}')";
|
||||
}
|
||||
|
||||
return $blueprint;
|
||||
};
|
||||
56
site/plugins/kirby-seo/blueprints/fields/robots.php
Normal file
56
site/plugins/kirby-seo/blueprints/fields/robots.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use tobimori\Seo\Meta;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return function (App $kirby) {
|
||||
if (!Seo::option('robots.active') || !Seo::option('robots.pageSettings')) {
|
||||
return [
|
||||
'type' => 'hidden'
|
||||
];
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'_robotsHeadline' => [
|
||||
'label' => 'seo.fields.robots.label',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
]
|
||||
];
|
||||
|
||||
$page = Meta::currentPage();
|
||||
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
|
||||
$upper = Str::ucfirst($robots);
|
||||
|
||||
$fields["robots{$upper}"] = [
|
||||
'label' => "seo.fields.robots.{$robots}.label",
|
||||
'type' => 'toggles',
|
||||
'help' => "seo.fields.robots.{$robots}.help",
|
||||
'width' => '1/2',
|
||||
'default' => 'default',
|
||||
'reset' => false,
|
||||
'options' => [
|
||||
'default' => $page ?
|
||||
A::join([
|
||||
t('seo.common.default'),
|
||||
$page->metadata()->get("robots{$upper}", ['fields'])->toBool() ? t('seo.common.yes') : t('seo.common.no')
|
||||
], ' ')
|
||||
: t('seo.common.default'),
|
||||
'true' => t('seo.common.yes'),
|
||||
'false' => t('seo.common.no'),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$fields['_seoLine3'] = [
|
||||
'type' => 'line'
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'group',
|
||||
'fields' => $fields,
|
||||
];
|
||||
};
|
||||
49
site/plugins/kirby-seo/blueprints/fields/site-robots.php
Normal file
49
site/plugins/kirby-seo/blueprints/fields/site-robots.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
|
||||
return function (App $kirby) {
|
||||
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
|
||||
return [
|
||||
'type' => 'hidden'
|
||||
];
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'_robotsHeadline' => [
|
||||
'label' => 'seo.fields.robots.label',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
|
||||
$index = $kirby->option('tobimori.seo.robots.index');
|
||||
if (is_callable($index)) {
|
||||
$index = $index();
|
||||
}
|
||||
|
||||
$fields["robots{$robots}"] = [
|
||||
'label' => "seo.fields.robots.{$robots}.label",
|
||||
'type' => 'toggles',
|
||||
'help' => "seo.fields.robots.{$robots}.help",
|
||||
'width' => '1/2',
|
||||
'default' => 'default',
|
||||
'reset' => false,
|
||||
'options' => [
|
||||
'default' => t('seo.common.default') . ' ' . ($index ? t('seo.common.yes') : t('seo.common.no')),
|
||||
'true' => t('seo.common.yes'),
|
||||
'false' => t('seo.common.no'),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$fields['_seoLine3'] = [
|
||||
'type' => 'line'
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'group',
|
||||
'fields' => $fields,
|
||||
];
|
||||
};
|
||||
30
site/plugins/kirby-seo/blueprints/fields/social-media.php
Normal file
30
site/plugins/kirby-seo/blueprints/fields/social-media.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Social Media Accounts field
|
||||
* Allows social media account list to be filled by config options
|
||||
*/
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return function () {
|
||||
$fields = [];
|
||||
|
||||
foreach (Seo::option('socialMedia') as $key => $value) {
|
||||
if ($value) {
|
||||
$fields[$key] = [
|
||||
'label' => ucfirst($key),
|
||||
'type' => 'url',
|
||||
'icon' => strtolower($key),
|
||||
'placeholder' => $value
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'seo.fields.socialMediaAccounts.label',
|
||||
'type' => 'object',
|
||||
'help' => 'seo.fields.socialMediaAccounts.help',
|
||||
'fields' => $fields
|
||||
];
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
type: seo-writer
|
||||
nodes:
|
||||
- seoTemplateTitle
|
||||
- seoTemplateSiteTitle
|
||||
toolbar:
|
||||
inline: false
|
||||
60
site/plugins/kirby-seo/blueprints/page.php
Normal file
60
site/plugins/kirby-seo/blueprints/page.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'label' => 'seo.tabs.seo',
|
||||
'icon' => 'search',
|
||||
'columns' => [
|
||||
'main' => [
|
||||
'width' => '7/12',
|
||||
'fields' => [
|
||||
'metaGroup' => 'seo/fields/meta-group',
|
||||
'ogGroup' => 'seo/fields/og-group',
|
||||
'robots' => 'seo/fields/robots',
|
||||
'metaInherit' => [
|
||||
'label' => 'seo.fields.inheritSettings.label',
|
||||
'type' => 'multiselect',
|
||||
'help' => 'seo.fields.inheritSettings.help',
|
||||
'options' => [
|
||||
'metaTemplate' => [
|
||||
'*' => 'seo.fields.metaTitleTemplate.label'
|
||||
],
|
||||
'metaDescription' => [
|
||||
'*' => 'seo.fields.metaDescription.label'
|
||||
],
|
||||
'ogTemplate' => [
|
||||
'*' => 'seo.fields.ogTitleTemplate.label'
|
||||
],
|
||||
'ogDescription' => [
|
||||
'*' => 'seo.fields.ogDescription.label'
|
||||
],
|
||||
'ogImage' => [
|
||||
'*' => 'seo.fields.ogImage.label'
|
||||
],
|
||||
'cropOgImage' => [
|
||||
'*' => 'seo.fields.cropOgImage.label'
|
||||
],
|
||||
'robots' => [
|
||||
'*' => 'seo.fields.robots.label'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'sidebar' => [
|
||||
'width' => '5/12',
|
||||
'sticky' => true,
|
||||
'sections' => [
|
||||
'seoPreview' => [
|
||||
'type' => 'seo-preview'
|
||||
],
|
||||
...(Seo::option('searchConsole.enabled') ? [
|
||||
'seoSearchConsole' => [
|
||||
'type' => 'seo-search-console'
|
||||
]
|
||||
] : [])
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
15
site/plugins/kirby-seo/blueprints/seo.php
Normal file
15
site/plugins/kirby-seo/blueprints/seo.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
return function (App $kirby) {
|
||||
$path = $kirby->request()->url()->toString();
|
||||
$isSite = Str::contains($path, '/site') && !Str::contains($path, '/pages/');
|
||||
|
||||
if ($isSite) {
|
||||
return require __DIR__ . '/site.php';
|
||||
}
|
||||
|
||||
return require __DIR__ . '/page.php';
|
||||
};
|
||||
95
site/plugins/kirby-seo/blueprints/site.php
Normal file
95
site/plugins/kirby-seo/blueprints/site.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
return [
|
||||
'label' => 'seo.tabs.seo',
|
||||
'icon' => 'search',
|
||||
'columns' => [
|
||||
'main' => [
|
||||
'width' => '7/12',
|
||||
'fields' => [
|
||||
'_metaHeadline' => [
|
||||
'label' => 'seo.site.meta.headline',
|
||||
'type' => 'headline',
|
||||
'help' => 'seo.site.meta.headline.help'
|
||||
],
|
||||
'metaTemplate' => [
|
||||
'extends' => 'seo/fields/title-template',
|
||||
'label' => 'seo.fields.metaTitleTemplate.label',
|
||||
'help' => 'seo.fields.metaTitleTemplate.help'
|
||||
],
|
||||
'metaDescription' => [
|
||||
'label' => 'seo.fields.metaDescription.label',
|
||||
'type' => 'seo-writer',
|
||||
'ai' => 'site-description',
|
||||
'help' => 'seo.fields.metaDescription.help'
|
||||
],
|
||||
'_seoLine1' => [
|
||||
'type' => 'line'
|
||||
],
|
||||
'_ogHeadline' => [
|
||||
'label' => 'seo.site.og.headline',
|
||||
'type' => 'headline',
|
||||
'numbered' => false,
|
||||
'help' => 'seo.site.og.headline.help'
|
||||
],
|
||||
'ogTemplate' => [
|
||||
'extends' => 'seo/fields/title-template',
|
||||
'label' => 'seo.fields.ogTitleTemplate.label',
|
||||
'default' => '{{ title }}',
|
||||
'help' => 'seo.fields.metaTitleTemplate.help',
|
||||
'placeholder' => '{{ site.metaTemplate }}'
|
||||
],
|
||||
'ogDescription' => [
|
||||
'label' => 'seo.fields.ogDescription.label',
|
||||
'type' => 'seo-writer',
|
||||
'ai' => 'og-site-description',
|
||||
'placeholder' => '{{ site.metaDescription }}'
|
||||
],
|
||||
'ogSiteName' => [
|
||||
'label' => 'seo.fields.ogSiteName.label',
|
||||
'type' => 'text',
|
||||
'default' => '{{ site.title }}',
|
||||
'placeholder' => '{{ site.title }}',
|
||||
'width' => '1/2'
|
||||
],
|
||||
'ogImage' => [
|
||||
'label' => 'seo.fields.ogImage.label',
|
||||
'extends' => 'seo/fields/og-image',
|
||||
'empty' => 'seo.fields.ogImage.empty',
|
||||
'width' => '1/2'
|
||||
],
|
||||
'cropOgImage' => [
|
||||
'label' => 'seo.fields.cropOgImage.label',
|
||||
'type' => 'toggle',
|
||||
'default' => true,
|
||||
'text' => [
|
||||
"{{ t('seo.common.no') }}",
|
||||
"{{ t('seo.common.yes') }}"
|
||||
],
|
||||
'help' => 'seo.fields.cropOgImage.help'
|
||||
],
|
||||
'_seoLine2' => [
|
||||
'type' => 'line'
|
||||
],
|
||||
'robots' => 'seo/fields/site-robots',
|
||||
'socialMediaAccounts' => 'seo/fields/social-media'
|
||||
]
|
||||
],
|
||||
'sidebar' => [
|
||||
'width' => '5/12',
|
||||
'sticky' => true,
|
||||
'sections' => [
|
||||
'seoPreview' => [
|
||||
'type' => 'seo-preview'
|
||||
],
|
||||
...(Seo::option('searchConsole.enabled') ? [
|
||||
'seoSearchConsole' => [
|
||||
'type' => 'seo-search-console'
|
||||
]
|
||||
] : [])
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
65
site/plugins/kirby-seo/classes/Ai.php
Normal file
65
site/plugins/kirby-seo/classes/Ai.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
|
||||
use function is_string;
|
||||
use function is_array;
|
||||
|
||||
/**
|
||||
* Ai facade
|
||||
*/
|
||||
class Ai
|
||||
{
|
||||
private static array $providers = [];
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return (bool)Seo::option('ai.enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a provider instance for the given ID or the default provider.
|
||||
*/
|
||||
public static function provider(string|null $providerId = null): Driver
|
||||
{
|
||||
$providerId ??= Seo::option('ai.provider');
|
||||
|
||||
if (isset(self::$providers[$providerId])) {
|
||||
return self::$providers[$providerId];
|
||||
}
|
||||
|
||||
$config = Seo::option("ai.providers.{$providerId}");
|
||||
if (!is_array($config)) {
|
||||
throw new KirbyException("AI provider \"{$providerId}\" is not defined.");
|
||||
}
|
||||
|
||||
$driver = $config['driver'] ?? null;
|
||||
if (!is_string($driver) || $driver === '') {
|
||||
throw new KirbyException("AI provider \"{$providerId}\" is missing a driver reference.");
|
||||
}
|
||||
|
||||
if (!is_subclass_of($driver, Driver::class)) {
|
||||
throw new KirbyException("AI provider driver \"{$driver}\" must extend " . Driver::class . '.');
|
||||
}
|
||||
|
||||
return self::$providers[$providerId] = new $driver($providerId);
|
||||
}
|
||||
|
||||
public static function streamTask(string $taskId, array $variables = []): Generator
|
||||
{
|
||||
$snippet = "seo/prompts/tasks/{$taskId}";
|
||||
$prompt = trim(snippet($snippet, $variables, return: true));
|
||||
if ($prompt === '') {
|
||||
throw new KirbyException("AI prompt snippet \"{$snippet}\" is missing or empty.");
|
||||
}
|
||||
|
||||
$content = [Content::user()->text($prompt)];
|
||||
|
||||
return self::provider()->stream($content);
|
||||
}
|
||||
}
|
||||
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
/**
|
||||
* Value object representing a streamed AI response chunk.
|
||||
*/
|
||||
final class Chunk
|
||||
{
|
||||
public const string TYPE_STREAM_START = 'stream-start';
|
||||
public const string TYPE_STREAM_END = 'stream-end';
|
||||
public const string TYPE_TEXT_START = 'text-start';
|
||||
public const string TYPE_TEXT_DELTA = 'text-delta';
|
||||
public const string TYPE_TEXT_COMPLETE = 'text-complete';
|
||||
public const string TYPE_THINKING_START = 'thinking-start';
|
||||
public const string TYPE_THINKING_DELTA = 'thinking-delta';
|
||||
public const string TYPE_THINKING_COMPLETE = 'thinking-complete';
|
||||
public const string TYPE_TOOL_CALL = 'tool-call';
|
||||
public const string TYPE_TOOL_RESULT = 'tool-result';
|
||||
public const string TYPE_ERROR = 'error';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $type,
|
||||
public readonly mixed $payload = null,
|
||||
public readonly ?string $text = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function streamStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_START, $payload);
|
||||
}
|
||||
|
||||
public static function streamEnd(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_END, $payload);
|
||||
}
|
||||
|
||||
public static function textStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_START, $payload);
|
||||
}
|
||||
|
||||
public static function textDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function textComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_START, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function thinkingComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function toolCall(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_CALL, $payload);
|
||||
}
|
||||
|
||||
public static function toolResult(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_RESULT, $payload);
|
||||
}
|
||||
|
||||
public static function error(string $message, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_ERROR, [
|
||||
'message' => $message,
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isStreamStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_START;
|
||||
}
|
||||
|
||||
public function isStreamEnd(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_END;
|
||||
}
|
||||
|
||||
public function isTextStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_START;
|
||||
}
|
||||
|
||||
public function isTextDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_DELTA;
|
||||
}
|
||||
|
||||
public function isTextComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_COMPLETE;
|
||||
}
|
||||
|
||||
public function isThinkingStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_START;
|
||||
}
|
||||
|
||||
public function isThinkingDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_DELTA;
|
||||
}
|
||||
|
||||
public function isThinkingComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_COMPLETE;
|
||||
}
|
||||
|
||||
public function isToolCall(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_CALL;
|
||||
}
|
||||
|
||||
public function isToolResult(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_RESULT;
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_ERROR;
|
||||
}
|
||||
}
|
||||
86
site/plugins/kirby-seo/classes/Ai/Content.php
Normal file
86
site/plugins/kirby-seo/classes/Ai/Content.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Imagick;
|
||||
use Kirby\Cms\File;
|
||||
|
||||
/**
|
||||
* Fluent builder for AI message content.
|
||||
* Each instance represents a single message with a role and content blocks.
|
||||
*/
|
||||
class Content
|
||||
{
|
||||
private string $role;
|
||||
private array $blocks = [];
|
||||
|
||||
private function __construct(string $role)
|
||||
{
|
||||
$this->role = $role;
|
||||
}
|
||||
|
||||
public static function user(): static
|
||||
{
|
||||
return new static('user');
|
||||
}
|
||||
|
||||
public static function assistant(): static
|
||||
{
|
||||
return new static('assistant');
|
||||
}
|
||||
|
||||
public static function system(): static
|
||||
{
|
||||
return new static('system');
|
||||
}
|
||||
|
||||
public function text(string $text): static
|
||||
{
|
||||
$this->blocks[] = ['type' => 'text', 'text' => $text];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an image block from a Kirby File, converted to WebP for smaller payloads.
|
||||
* Non-resizable formats (SVG, etc.) are rasterized via Imagick.
|
||||
*/
|
||||
public function image(File $file, int $maxDimension = 1024): static
|
||||
{
|
||||
if ($file->isResizable()) {
|
||||
$thumb = $file->thumb([
|
||||
'width' => $maxDimension,
|
||||
'height' => $maxDimension,
|
||||
'format' => 'webp',
|
||||
]);
|
||||
|
||||
$data = base64_encode($thumb->read());
|
||||
} else {
|
||||
// TODO: better handling without ext-imagick
|
||||
$imagick = new Imagick();
|
||||
$imagick->readImage($file->root());
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->thumbnailImage($maxDimension, $maxDimension, true);
|
||||
$data = base64_encode($imagick->getImageBlob());
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
}
|
||||
|
||||
$this->blocks[] = [
|
||||
'type' => 'image',
|
||||
'data' => $data,
|
||||
'mediaType' => 'image/webp',
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function role(): string
|
||||
{
|
||||
return $this->role;
|
||||
}
|
||||
|
||||
public function blocks(): array
|
||||
{
|
||||
return $this->blocks;
|
||||
}
|
||||
}
|
||||
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
abstract class Driver
|
||||
{
|
||||
public function __construct(protected string $providerId)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a response for the given content.
|
||||
*
|
||||
* @param array<Content> $content Array of Content messages forming a conversation.
|
||||
* @param string|null $model Model override.
|
||||
*
|
||||
* @return Generator<int, Chunk, mixed, void>
|
||||
*/
|
||||
abstract public function stream(array $content, string|null $model = null): Generator;
|
||||
|
||||
/**
|
||||
* Returns a configuration value or throws when required.
|
||||
*/
|
||||
protected function config(string $key, mixed $default = null, bool $required = false): mixed
|
||||
{
|
||||
$value = Seo::option("ai.providers.{$this->providerId}.config.{$key}", $default);
|
||||
|
||||
if ($required === true && ($value === null || $value === '')) {
|
||||
throw new InvalidArgumentException(
|
||||
"Missing required \"{$key}\" configuration for driver " . static::class . '.'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
140
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
140
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class Anthropic extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
||||
protected const string DEFAULT_MODEL = 'claude-4-5-haiku';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"x-api-key: {$apiKey}",
|
||||
'anthropic-version: 2023-06-01',
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'messages' => $this->buildMessages($content),
|
||||
'max_tokens' => 4096,
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
// handle message start event
|
||||
if ($type === 'message_start') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block start (beginning of text output)
|
||||
if ($type === 'content_block_start') {
|
||||
$contentBlock = $event['content_block'] ?? [];
|
||||
if (($contentBlock['type'] ?? null) === 'text') {
|
||||
yield Chunk::textStart($event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block delta (text chunks)
|
||||
if ($type === 'content_block_delta') {
|
||||
$delta = $event['delta'] ?? [];
|
||||
if (($delta['type'] ?? null) === 'text_delta') {
|
||||
$text = $delta['text'] ?? '';
|
||||
if ($text !== '') {
|
||||
yield Chunk::textDelta($text, $event);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block stop (end of text block)
|
||||
if ($type === 'content_block_stop') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message stop (end of stream)
|
||||
if ($type === 'message_stop') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle ping events (keep-alive)
|
||||
if ($type === 'ping') {
|
||||
// ignore ping events
|
||||
return;
|
||||
}
|
||||
|
||||
// handle error events
|
||||
if ($type === 'error') {
|
||||
$error = $event['error'] ?? [];
|
||||
$message = $error['message'] ?? 'Unknown Anthropic streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message delta (contains usage info)
|
||||
if ($type === 'message_delta') {
|
||||
// we could extract usage info here if needed
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the Anthropic messages format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildMessages(array $content): array
|
||||
{
|
||||
$messages = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
$blocks = [];
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$blocks[] = [
|
||||
'type' => 'image',
|
||||
'source' => [
|
||||
'type' => 'base64',
|
||||
'media_type' => $block['mediaType'],
|
||||
'data' => $block['data'],
|
||||
],
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$blocks[] = [
|
||||
'type' => 'text',
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'role' => $message->role(),
|
||||
'content' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
}
|
||||
149
site/plugins/kirby-seo/classes/Ai/Drivers/Gemini.php
Normal file
149
site/plugins/kirby-seo/classes/Ai/Drivers/Gemini.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class Gemini extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
protected const string DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$model = $model ?? $this->config('model', static::DEFAULT_MODEL);
|
||||
$baseEndpoint = $this->config('endpoint', static::DEFAULT_ENDPOINT);
|
||||
$endpoint = "{$baseEndpoint}/models/{$model}:streamGenerateContent?alt=sse&key={$apiKey}";
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'contents' => $this->buildContents($content),
|
||||
];
|
||||
|
||||
$systemInstruction = $this->buildSystemInstruction($content);
|
||||
if ($systemInstruction !== null) {
|
||||
$payload['systemInstruction'] = $systemInstruction;
|
||||
}
|
||||
|
||||
$stream = new SseStream($endpoint, $headers, $payload, (int)$this->config('timeout', 120));
|
||||
$started = false;
|
||||
|
||||
yield from $stream->stream(function (array $event) use (&$started): Generator {
|
||||
$candidates = $event['candidates'] ?? [];
|
||||
$candidate = $candidates[0] ?? null;
|
||||
|
||||
if ($candidate === null) {
|
||||
$error = $event['error'] ?? null;
|
||||
if ($error) {
|
||||
yield Chunk::error($error['message'] ?? 'Unknown Gemini error.', $event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$started) {
|
||||
yield Chunk::streamStart($event);
|
||||
yield Chunk::textStart($event);
|
||||
$started = true;
|
||||
}
|
||||
|
||||
$finishReason = $candidate['finishReason'] ?? null;
|
||||
if ($finishReason === 'SAFETY') {
|
||||
yield Chunk::error('Response blocked by safety filters.', $event);
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = $candidate['content']['parts'] ?? [];
|
||||
foreach ($parts as $part) {
|
||||
$text = $part['text'] ?? '';
|
||||
if ($text !== '') {
|
||||
yield Chunk::textDelta($text, $event);
|
||||
}
|
||||
}
|
||||
|
||||
if ($finishReason !== null) {
|
||||
yield Chunk::textComplete($event);
|
||||
yield Chunk::streamEnd($event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the Gemini contents format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildContents(array $content): array
|
||||
{
|
||||
$contents = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
if ($message->role() === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$parts[] = [
|
||||
'inline_data' => [
|
||||
'mime_type' => $block['mediaType'],
|
||||
'data' => $block['data'],
|
||||
],
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$parts[] = [
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$contents[] = [
|
||||
'role' => $message->role() === 'assistant' ? 'model' : 'user',
|
||||
'parts' => $parts,
|
||||
];
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts system messages into a Gemini systemInstruction object.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildSystemInstruction(array $content): array|null
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
if ($message->role() !== 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'text') {
|
||||
$parts[] = ['text' => $block['text']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['parts' => $parts];
|
||||
}
|
||||
}
|
||||
118
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
118
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class OpenAi extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/responses';
|
||||
protected const string DEFAULT_MODEL = 'gpt-5-mini-2025-08-07';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"Authorization: Bearer {$apiKey}",
|
||||
];
|
||||
if ($organization = $this->config('organization')) {
|
||||
$headers[] = "OpenAI-Organization: {$organization}";
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'input' => $this->buildInput($content),
|
||||
// instructions does not work for e.g. openrouter so let's just put everything in user prompt
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
if ($type === 'response.created') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.in_progress') {
|
||||
yield Chunk::textStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.delta') {
|
||||
$delta = $event['delta'] ?? '';
|
||||
if ($delta !== '') {
|
||||
yield Chunk::textDelta($delta, $event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.done') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.completed') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_item.added' && ($event['item']['type'] ?? null) === 'reasoning') {
|
||||
yield Chunk::thinkingStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.error') {
|
||||
$message = $event['error']['message'] ?? 'Unknown OpenAI streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the OpenAI Responses API input format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildInput(array $content): array
|
||||
{
|
||||
$input = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
$blocks = [];
|
||||
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$blocks[] = [
|
||||
'type' => 'input_image',
|
||||
'image_url' => "data:{$block['mediaType']};base64,{$block['data']}",
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$blocks[] = [
|
||||
'type' => 'input_text',
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$input[] = [
|
||||
'role' => $message->role(),
|
||||
'content' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
169
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAiCompletions.php
Normal file
169
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAiCompletions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
194
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
194
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
|
||||
use function curl_errno;
|
||||
use function curl_error;
|
||||
use function curl_getinfo;
|
||||
use function curl_init;
|
||||
use function curl_multi_add_handle;
|
||||
use function curl_multi_close;
|
||||
use function curl_multi_exec;
|
||||
use function curl_multi_init;
|
||||
use function curl_multi_remove_handle;
|
||||
use function curl_multi_select;
|
||||
use function curl_setopt_array;
|
||||
use function strlen;
|
||||
use function sprintf;
|
||||
use function is_array;
|
||||
|
||||
use const CURLOPT_HTTPHEADER;
|
||||
use const CURLOPT_POST;
|
||||
use const CURLOPT_POSTFIELDS;
|
||||
use const CURLOPT_RETURNTRANSFER;
|
||||
use const CURLOPT_TIMEOUT;
|
||||
use const CURLOPT_WRITEFUNCTION;
|
||||
use const CURLINFO_HTTP_CODE;
|
||||
use const CURLM_CALL_MULTI_PERFORM;
|
||||
|
||||
final class SseStream
|
||||
{
|
||||
private const int ERROR_CONTEXT_LIMIT = 8192;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
private readonly array $headers,
|
||||
private readonly array $payload,
|
||||
private readonly int $timeout = 120
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
public function stream(callable $mapper): Generator
|
||||
{
|
||||
$buffer = '';
|
||||
$response = '';
|
||||
$handle = curl_init($this->endpoint);
|
||||
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $this->headers,
|
||||
CURLOPT_POSTFIELDS => json_encode(
|
||||
$this->payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, $data) use (&$buffer, &$response) {
|
||||
$buffer .= $data;
|
||||
$currentLength = strlen($response);
|
||||
|
||||
if ($currentLength < self::ERROR_CONTEXT_LIMIT) {
|
||||
$response .= substr($data, 0, self::ERROR_CONTEXT_LIMIT - $currentLength);
|
||||
}
|
||||
|
||||
return strlen($data);
|
||||
},
|
||||
]);
|
||||
|
||||
$multi = curl_multi_init();
|
||||
curl_multi_add_handle($multi, $handle);
|
||||
|
||||
try {
|
||||
$running = null;
|
||||
do {
|
||||
$status = curl_multi_exec($multi, $running);
|
||||
|
||||
if ($status === CURLM_CALL_MULTI_PERFORM) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper);
|
||||
|
||||
if ($running) {
|
||||
curl_multi_select($multi, 0.1);
|
||||
}
|
||||
} while ($running);
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper, true);
|
||||
|
||||
$errno = curl_errno($handle);
|
||||
if ($errno) {
|
||||
throw new KirbyException(curl_error($handle) ?: 'Streaming request failed.', $errno);
|
||||
}
|
||||
|
||||
$code = curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($code !== null && $code >= 400) {
|
||||
$message = sprintf('Streaming request failed (%d)', $code);
|
||||
$body = trim($response);
|
||||
|
||||
if ($body !== '') {
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$body = $decoded['error']['message'] ?? $decoded['message'] ?? $body;
|
||||
}
|
||||
|
||||
if (strlen($body) > 200) {
|
||||
$body = substr($body, 0, 200) . '...';
|
||||
}
|
||||
|
||||
$message .= ': ' . preg_replace('/\s+/', ' ', $body);
|
||||
}
|
||||
|
||||
throw new KirbyException($message);
|
||||
}
|
||||
} finally {
|
||||
curl_multi_remove_handle($multi, $handle);
|
||||
curl_multi_close($multi);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function drainBuffer(string &$buffer, callable $mapper, bool $final = false): Generator
|
||||
{
|
||||
while (
|
||||
preg_match('/\r?\n\r?\n/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1
|
||||
) {
|
||||
$pos = $match[0][1];
|
||||
$len = strlen($match[0][0]);
|
||||
$frame = substr($buffer, 0, $pos);
|
||||
$buffer = substr($buffer, $pos + $len);
|
||||
|
||||
foreach ($this->mapFrame($frame, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
if ($final && trim($buffer) !== '') {
|
||||
foreach ($this->mapFrame($buffer, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function mapFrame(string $frame, callable $mapper): Generator
|
||||
{
|
||||
$frame = trim($frame);
|
||||
|
||||
if ($frame === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = '';
|
||||
|
||||
foreach (preg_split("/\r\n|\n|\r/", $frame) as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || str_starts_with($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$payload .= substr($line, 5);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = trim($payload);
|
||||
if ($payload === '' || $payload === '[DONE]') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = json_decode($payload, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield from $mapper($event);
|
||||
}
|
||||
}
|
||||
126
site/plugins/kirby-seo/classes/AltText.php
Normal file
126
site/plugins/kirby-seo/classes/AltText.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Data\Yaml;
|
||||
|
||||
/**
|
||||
* Value object for structured alt text data.
|
||||
* Handles both YAML object format and plain string migration.
|
||||
*/
|
||||
class AltText
|
||||
{
|
||||
public const string SOURCE_AI = 'ai';
|
||||
public const string SOURCE_MANUAL = 'manual';
|
||||
public const string SOURCE_REVIEWED = 'reviewed';
|
||||
|
||||
public function __construct(
|
||||
protected string $text = '',
|
||||
protected bool $decorative = false,
|
||||
protected string $source = self::SOURCE_MANUAL,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw field value into an AltText instance.
|
||||
* Handles YAML object format and plain string migration.
|
||||
*/
|
||||
public static function parse(string|null $value): static
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return new static();
|
||||
}
|
||||
|
||||
try {
|
||||
$data = Yaml::decode($value);
|
||||
|
||||
if (is_array($data) && (array_key_exists('text', $data) || array_key_exists('decorative', $data))) {
|
||||
return new static(
|
||||
text: (string)($data['text'] ?? ''),
|
||||
decorative: (bool)($data['decorative'] ?? false),
|
||||
source: (string)($data['source'] ?? self::SOURCE_MANUAL),
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// not valid YAML
|
||||
}
|
||||
|
||||
// plain string = migration from old alt field
|
||||
return new static(text: $value, source: self::SOURCE_MANUAL);
|
||||
}
|
||||
|
||||
public static function fromField(Field $field): static
|
||||
{
|
||||
return static::parse($field->value());
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isDecorative(): bool
|
||||
{
|
||||
return $this->decorative;
|
||||
}
|
||||
|
||||
public function source(): string
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function isMissing(): bool
|
||||
{
|
||||
return !$this->decorative && trim($this->text) === '';
|
||||
}
|
||||
|
||||
public function isAiGenerated(): bool
|
||||
{
|
||||
return $this->source === self::SOURCE_AI;
|
||||
}
|
||||
|
||||
public function isReviewed(): bool
|
||||
{
|
||||
return $this->source === self::SOURCE_REVIEWED;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'text' => $this->text,
|
||||
'decorative' => $this->decorative,
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML attributes for the alt text.
|
||||
*/
|
||||
public function toAttr(): array
|
||||
{
|
||||
return ['alt' => $this->decorative ? '' : $this->text];
|
||||
}
|
||||
|
||||
public function toYaml(): string
|
||||
{
|
||||
return Yaml::encode($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved alt text as a Kirby Field for chaining (.or(), .isNotEmpty(), etc.)
|
||||
*/
|
||||
public function toField(): Field
|
||||
{
|
||||
return new Field(null, 'alt', (string)$this);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->decorative) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
41
site/plugins/kirby-seo/classes/Buttons/RobotsViewButton.php
Normal file
41
site/plugins/kirby-seo/classes/Buttons/RobotsViewButton.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButton;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
class RobotsViewButton extends ViewButton
|
||||
{
|
||||
public function __construct(Page $model)
|
||||
{
|
||||
$robots = $model->robots();
|
||||
|
||||
$theme = 'positive-icon';
|
||||
$icon = 'robots';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.index');
|
||||
|
||||
if (Str::contains($robots, 'no') && !Str::contains($robots, 'noindex')) {
|
||||
$theme = 'notice-icon';
|
||||
$icon = 'robots-off';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.any');
|
||||
}
|
||||
|
||||
if (Str::contains($robots, 'noindex')) {
|
||||
$theme = 'negative-icon';
|
||||
$icon = 'robots-off';
|
||||
$text = I18n::translate('seo.fields.robots.indicator.noindex');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
model: $model,
|
||||
icon: $icon,
|
||||
text: $text,
|
||||
theme: $theme,
|
||||
link: $model->panel()->url() . '?tab=seo',
|
||||
responsive: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButton;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
class UtmShareViewButton extends ViewButton
|
||||
{
|
||||
public function __construct(Page|Site $model)
|
||||
{
|
||||
parent::__construct(
|
||||
model: $model,
|
||||
dialog: "seo/utm-share/{$model->panel()->path()}",
|
||||
icon: 'share',
|
||||
title: I18n::translate('seo.utmShare.button')
|
||||
);
|
||||
}
|
||||
}
|
||||
39
site/plugins/kirby-seo/classes/Dialogs/UtmShareDialog.php
Normal file
39
site/plugins/kirby-seo/classes/Dialogs/UtmShareDialog.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Dialogs;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
|
||||
class UtmShareDialog
|
||||
{
|
||||
protected Page|Site $model;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($path === 'site') {
|
||||
$this->model = $kirby->site();
|
||||
} else {
|
||||
$id = preg_replace('/^pages\//', '', $path);
|
||||
$this->model = Find::page($id);
|
||||
}
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$url = $this->model instanceof Site
|
||||
? $this->model->homePage()->url()
|
||||
: $this->model->url();
|
||||
|
||||
return [
|
||||
'component' => 'k-seo-utm-share-dialog',
|
||||
'props' => [
|
||||
'pageUrl' => $url
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
316
site/plugins/kirby-seo/classes/Field/AltTextField.php
Normal file
316
site/plugins/kirby-seo/classes/Field/AltTextField.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Field;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Form\Field;
|
||||
use Kirby\Form\FieldClass;
|
||||
use Kirby\Http\Response;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\AltText;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
class AltTextField extends FieldClass
|
||||
{
|
||||
protected bool $ai;
|
||||
protected bool $autogenerate;
|
||||
protected mixed $value = [];
|
||||
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$this->autogenerate = $params['autogenerate'] ?? false;
|
||||
parent::__construct($params);
|
||||
$this->setAi($params['ai'] ?? true);
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return 'alt-text';
|
||||
}
|
||||
|
||||
protected function setAi(bool $ai = true): void
|
||||
{
|
||||
if ($ai && !Seo::option('components.ai')::enabled()) {
|
||||
$ai = false;
|
||||
}
|
||||
|
||||
if ($ai && App::instance()->user()?->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
$ai = false;
|
||||
}
|
||||
|
||||
$this->ai = $ai;
|
||||
}
|
||||
|
||||
public function ai(): bool
|
||||
{
|
||||
return $this->ai;
|
||||
}
|
||||
|
||||
public function autogenerate(): bool
|
||||
{
|
||||
return $this->autogenerate;
|
||||
}
|
||||
|
||||
public function fill(mixed $value): static
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$this->value = $value;
|
||||
} else {
|
||||
$this->value = AltText::parse($value)->toArray();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toFormValue(): mixed
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toStoredValue(): mixed
|
||||
{
|
||||
if (is_array($this->value)) {
|
||||
$altText = new AltText(
|
||||
text: $this->value['text'] ?? '',
|
||||
decorative: $this->value['decorative'] ?? false,
|
||||
source: $this->value['source'] ?? AltText::SOURCE_MANUAL,
|
||||
);
|
||||
|
||||
if ($altText->isMissing()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $altText->toYaml();
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'ai' => $this->ai(),
|
||||
'autogenerate' => $this->autogenerate(),
|
||||
];
|
||||
}
|
||||
|
||||
public function routes(): array
|
||||
{
|
||||
$field = $this;
|
||||
|
||||
return [
|
||||
[
|
||||
'pattern' => 'ai/stream',
|
||||
'method' => 'POST',
|
||||
'action' => function () use ($field) {
|
||||
$kirby = App::instance();
|
||||
$component = Seo::option('components.ai');
|
||||
|
||||
if (!$component::enabled()) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.disabled')
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($kirby->user()->role()->permissions()->for('tobimori.seo', 'ai') === false) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('seo.ai.error.permission')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$model = $field->model();
|
||||
|
||||
if (!$model instanceof File || $model->type() !== 'image') {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Field must be on an image file.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$data = $kirby->request()->body()->data();
|
||||
$lang = $kirby->api()->language();
|
||||
|
||||
if ($lang) {
|
||||
$kirby->setCurrentLanguage($lang);
|
||||
}
|
||||
|
||||
// begin SSE stream
|
||||
ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
echo ":ok\n\n";
|
||||
flush();
|
||||
|
||||
$send = static function (array $event): void {
|
||||
echo 'data: ' . json_encode(
|
||||
$event,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
) . "\n\n";
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_flush();
|
||||
}
|
||||
|
||||
flush();
|
||||
};
|
||||
|
||||
try {
|
||||
$kirby->data = [
|
||||
'file' => $model,
|
||||
'site' => $kirby->site(),
|
||||
'kirby' => $kirby,
|
||||
];
|
||||
|
||||
$prompt = trim(snippet('seo/prompts/tasks/alt-text', [
|
||||
'file' => $model,
|
||||
'instructions' => $data['instructions'] ?? null,
|
||||
], return: true));
|
||||
|
||||
$content = [
|
||||
Content::user()
|
||||
->image($model)
|
||||
->text($prompt),
|
||||
];
|
||||
|
||||
foreach ($component::provider()->stream($content) as $chunk) {
|
||||
$send([
|
||||
'type' => $chunk->type,
|
||||
'text' => $chunk->text,
|
||||
'payload' => $chunk->payload,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$send([
|
||||
'type' => 'error',
|
||||
'payload' => [
|
||||
'message' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates alt text for all autogenerate-enabled fields on a file.
|
||||
* Handles both single-lang and multi-lang sites in a single AI call.
|
||||
*/
|
||||
public static function generateForFile(File $file): File
|
||||
{
|
||||
if ($file->type() !== 'image') {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$component = Seo::option('components.ai');
|
||||
if (!$component::enabled()) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$blueprint = $file->blueprint();
|
||||
$autogenerateFields = [];
|
||||
|
||||
foreach ($blueprint->fields() as $name => $field) {
|
||||
$fieldClass = Field::$types[$field['type'] ?? ''] ?? null;
|
||||
if (!is_a($fieldClass, static::class, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($field['autogenerate'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$autogenerateFields[] = $name;
|
||||
}
|
||||
|
||||
if ($autogenerateFields === []) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$kirby = $file->kirby();
|
||||
$languages = $kirby->languages();
|
||||
$isMultiLang = $languages->isNotEmpty();
|
||||
|
||||
$kirby->data = [
|
||||
'file' => $file,
|
||||
'site' => $kirby->site(),
|
||||
'kirby' => $kirby,
|
||||
];
|
||||
|
||||
$langCodes = $isMultiLang
|
||||
? $languages->pluck('code')
|
||||
: [];
|
||||
|
||||
$prompt = trim(snippet('seo/prompts/tasks/alt-text', [
|
||||
'file' => $file,
|
||||
'languages' => $langCodes,
|
||||
], return: true));
|
||||
|
||||
$content = [
|
||||
Content::user()
|
||||
->image($file)
|
||||
->text($prompt),
|
||||
];
|
||||
|
||||
$text = '';
|
||||
foreach ($component::provider()->stream($content) as $chunk) {
|
||||
if ($chunk->type === 'text-delta') {
|
||||
$text .= $chunk->text;
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return $file;
|
||||
}
|
||||
|
||||
// parse into [langCode => altText] map (single-lang uses null key)
|
||||
$results = [];
|
||||
|
||||
if ($isMultiLang) {
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || !str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colonPos = strpos($line, ':');
|
||||
$code = trim(substr($line, 0, $colonPos));
|
||||
$value = trim(substr($line, $colonPos + 1));
|
||||
|
||||
if ($value !== '' && in_array($code, $langCodes, true)) {
|
||||
$results[$code] = $value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$results[null] = $text;
|
||||
}
|
||||
|
||||
return $kirby->impersonate('kirby', function () use ($file, $results, $autogenerateFields) {
|
||||
foreach ($results as $langCode => $altText) {
|
||||
$updates = [];
|
||||
foreach ($autogenerateFields as $name) {
|
||||
$updates[$name] = (new AltText(text: $altText, source: AltText::SOURCE_AI))->toYaml();
|
||||
}
|
||||
|
||||
$file = $file->update($updates, $langCode);
|
||||
}
|
||||
|
||||
return $file;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue