SEO : add tombi mori plugin
This commit is contained in:
parent
df2843123f
commit
8f9e75126e
64 changed files with 3719 additions and 44 deletions
|
|
@ -28,9 +28,13 @@
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"php-http/guzzle7-adapter": "^1.1",
|
"php-http/guzzle7-adapter": "^1.1",
|
||||||
"mailersend/mailersend": "^0.28.0",
|
"mailersend/mailersend": "^0.28.0",
|
||||||
"sylvainjule/code-editor": "^1.0"
|
"sylvainjule/code-editor": "^1.0",
|
||||||
|
"tobimori/kirby-seo": "^1.1"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"platform": {
|
||||||
|
"php": "8.3.0"
|
||||||
|
},
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"getkirby/composer-installer": true,
|
"getkirby/composer-installer": true,
|
||||||
"php-http/discovery": true
|
"php-http/discovery": true
|
||||||
|
|
|
||||||
135
composer.lock
generated
135
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "5dfa3f8a0e0c08424cf5148eb08e1ab9",
|
"content-hash": "30f9edc8f90ec79150fffac01e3b80fd",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "beberlei/assert",
|
"name": "beberlei/assert",
|
||||||
|
|
@ -2220,6 +2220,79 @@
|
||||||
},
|
},
|
||||||
"time": "2019-03-08T08:55:37+00:00"
|
"time": "2019-03-08T08:55:37+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/schema-org",
|
||||||
|
"version": "3.23.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/schema-org.git",
|
||||||
|
"reference": "a8dc1b6fcdd06afc1ab084c3ead9b7a4c3d7a35d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/schema-org/zipball/a8dc1b6fcdd06afc1ab084c3ead9b7a4c3d7a35d",
|
||||||
|
"reference": "a8dc1b6fcdd06afc1ab084c3ead9b7a4c3d7a35d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.6",
|
||||||
|
"graham-campbell/analyzer": "^4.2",
|
||||||
|
"illuminate/collections": "^8.62.0",
|
||||||
|
"league/flysystem": "^2.3.0 || ^3.0",
|
||||||
|
"pestphp/pest": "^1.21",
|
||||||
|
"symfony/console": "^5.3.7 || 6.0",
|
||||||
|
"twig/twig": "^3.3.3"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\SchemaOrg\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sebastian De Deyne",
|
||||||
|
"email": "sebastian@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tom Witkowski",
|
||||||
|
"email": "dev.gummibeer@gmail.com",
|
||||||
|
"homepage": "https://gummibeer.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A fluent builder Schema.org types and ld+json generator",
|
||||||
|
"homepage": "https://github.com/spatie/schema-org",
|
||||||
|
"keywords": [
|
||||||
|
"schema-org",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/schema-org/issues",
|
||||||
|
"source": "https://github.com/spatie/schema-org/tree/3.23.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://spatie.be/open-source/support-us",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-01-31T14:54:12+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sylvainjule/code-editor",
|
"name": "sylvainjule/code-editor",
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
|
|
@ -3122,6 +3195,63 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-01-07T09:44:41+00:00"
|
"time": "2025-01-07T09:44:41+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tobimori/kirby-seo",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tobimori/kirby-seo.git",
|
||||||
|
"reference": "a06eb676f699797fdd04a515149559ffd4746be7"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/tobimori/kirby-seo/zipball/a06eb676f699797fdd04a515149559ffd4746be7",
|
||||||
|
"reference": "a06eb676f699797fdd04a515149559ffd4746be7",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"getkirby/composer-installer": "^1.2.1",
|
||||||
|
"php": ">=8.1.0",
|
||||||
|
"spatie/schema-org": "^3.14"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.48",
|
||||||
|
"getkirby/cli": "^1.2",
|
||||||
|
"getkirby/cms": "^4.0"
|
||||||
|
},
|
||||||
|
"type": "kirby-plugin",
|
||||||
|
"extra": {
|
||||||
|
"kirby-cms-path": false
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"tobimori\\Seo\\": "classes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tobias Möritz",
|
||||||
|
"email": "tobias@moeritz.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The ultimate Kirby SEO toolkit",
|
||||||
|
"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/1.1.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/tobimori",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-04-10T09:49:19+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [],
|
"packages-dev": [],
|
||||||
|
|
@ -3134,5 +3264,8 @@
|
||||||
"php": "~8.1.0 || ~8.2.0 || ~8.3.0"
|
"php": "~8.1.0 || ~8.2.0 || ~8.3.0"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": [],
|
||||||
|
"platform-overrides": {
|
||||||
|
"php": "8.3.0"
|
||||||
|
},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,6 @@ tabs:
|
||||||
type: pages
|
type: pages
|
||||||
create: false
|
create: false
|
||||||
query: page.getTexts()
|
query: page.getTexts()
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,6 @@ tabs:
|
||||||
label: Liste
|
label: Liste
|
||||||
type: pages
|
type: pages
|
||||||
template: author
|
template: author
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,6 @@ tabs:
|
||||||
type: email
|
type: email
|
||||||
sendBtn:
|
sendBtn:
|
||||||
type: send-button
|
type: send-button
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,7 @@ tabs:
|
||||||
- quote
|
- quote
|
||||||
|
|
||||||
metaTab: tabs/meta
|
metaTab: tabs/meta
|
||||||
|
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,6 @@ tabs:
|
||||||
type: fields
|
type: fields
|
||||||
fields:
|
fields:
|
||||||
body: fields/body
|
body: fields/body
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,7 @@ tabs:
|
||||||
paramsTab: tabs/params
|
paramsTab: tabs/params
|
||||||
|
|
||||||
metaTab: tabs/meta
|
metaTab: tabs/meta
|
||||||
|
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@ tabs:
|
||||||
type: pages
|
type: pages
|
||||||
template: email
|
template: email
|
||||||
info: "{{ page.status == 'listed' ? 'envoyée' : 'brouillon' }}"
|
info: "{{ page.status == 'listed' ? 'envoyée' : 'brouillon' }}"
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
title: texts
|
title: texts
|
||||||
|
|
||||||
columns:
|
tabs:
|
||||||
- width: 1/2
|
contentTab:
|
||||||
fields:
|
label: Contenu
|
||||||
categories:
|
columns:
|
||||||
type: tags
|
- width: 1/2
|
||||||
- width: 1/2
|
fields:
|
||||||
sections:
|
categories:
|
||||||
yearsSection:
|
type: tags
|
||||||
label: Années
|
- width: 1/2
|
||||||
type: pages
|
sections:
|
||||||
template: year
|
yearsSection:
|
||||||
sortBy: title desc
|
label: Années
|
||||||
- width: 1/1
|
type: pages
|
||||||
sections:
|
template: year
|
||||||
allTextsSection:
|
sortBy: title desc
|
||||||
label: Tous les textes
|
- width: 1/1
|
||||||
type: pages
|
sections:
|
||||||
create: false
|
allTextsSection:
|
||||||
search: true
|
label: Tous les textes
|
||||||
query: page.children.children
|
type: pages
|
||||||
info: "{{ page.author.toPage.title }} [{{ page.category }}]"
|
create: false
|
||||||
sortBy: modified desc
|
search: true
|
||||||
|
query: page.children.children
|
||||||
|
info: "{{ page.author.toPage.title }} [{{ page.category }}]"
|
||||||
|
sortBy: modified desc
|
||||||
|
seo:
|
||||||
|
extends: seo/page
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,30 @@ image:
|
||||||
back: black
|
back: black
|
||||||
color: white
|
color: white
|
||||||
|
|
||||||
columns:
|
tabs:
|
||||||
- width: 1/3
|
contentTab:
|
||||||
sections:
|
label: Contenu
|
||||||
fieldsSection:
|
columns:
|
||||||
type: fields
|
- width: 1/3
|
||||||
|
sections:
|
||||||
|
fieldsSection:
|
||||||
|
type: fields
|
||||||
|
fields:
|
||||||
|
openDate:
|
||||||
|
label: Date d'ouverture
|
||||||
|
type: date
|
||||||
|
display: DD/MM/YYYY
|
||||||
|
texts:
|
||||||
|
label: Textes
|
||||||
|
type: pages
|
||||||
|
help: **Pour réorganiser les textes**, utiliser la poignée ⁝⁝ qui apparait au survol.
|
||||||
|
templates:
|
||||||
|
- linear
|
||||||
|
- grid
|
||||||
|
- width: 2/3
|
||||||
fields:
|
fields:
|
||||||
openDate:
|
edito:
|
||||||
label: Date d'ouverture
|
type: writer
|
||||||
type: date
|
seo:
|
||||||
display: DD/MM/YYYY
|
extends: seo/page
|
||||||
texts:
|
label: Indexation
|
||||||
label: Textes
|
|
||||||
type: pages
|
|
||||||
help: **Pour réorganiser les textes**, utiliser la poignée ⁝⁝ qui apparait au survol.
|
|
||||||
templates:
|
|
||||||
- linear
|
|
||||||
- grid
|
|
||||||
- width: 2/3
|
|
||||||
fields:
|
|
||||||
edito:
|
|
||||||
type: writer
|
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@ tabs:
|
||||||
nodes: false
|
nodes: false
|
||||||
marks: false
|
marks: false
|
||||||
edito: fields/body
|
edito: fields/body
|
||||||
|
seo:
|
||||||
|
extends: seo/site
|
||||||
|
label: Indexation
|
||||||
|
|
|
||||||
4
site/plugins/kirby-seo/.husky/pre-commit
Executable file
4
site/plugins/kirby-seo/.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm run build && git add index.css index.js
|
||||||
1
site/plugins/kirby-seo/.nvmrc
Normal file
1
site/plugins/kirby-seo/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20
|
||||||
59
site/plugins/kirby-seo/.php-cs-fixer.dist.php
Normal file
59
site/plugins/kirby-seo/.php-cs-fixer.dist.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$finder = PhpCsFixer\Finder::create()
|
||||||
|
->exclude('vendor')
|
||||||
|
->in(__DIR__);
|
||||||
|
|
||||||
|
$config = new PhpCsFixer\Config();
|
||||||
|
return $config
|
||||||
|
->setRules([
|
||||||
|
'@PSR12' => true,
|
||||||
|
'align_multiline_comment' => ['comment_type' => 'phpdocs_like'],
|
||||||
|
'array_indentation' => true,
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'cast_spaces' => ['space' => 'none'],
|
||||||
|
// 'class_keyword_remove' => true, // replaces static::class with 'static' (won't work)
|
||||||
|
'combine_consecutive_issets' => true,
|
||||||
|
'combine_consecutive_unsets' => true,
|
||||||
|
'combine_nested_dirname' => true,
|
||||||
|
'concat_space' => ['spacing' => 'one'],
|
||||||
|
'declare_equal_normalize' => ['space' => 'single'],
|
||||||
|
'dir_constant' => true,
|
||||||
|
'function_typehint_space' => true,
|
||||||
|
'include' => true,
|
||||||
|
'logical_operators' => true,
|
||||||
|
'lowercase_cast' => true,
|
||||||
|
'lowercase_static_reference' => true,
|
||||||
|
'magic_constant_casing' => true,
|
||||||
|
'magic_method_casing' => true,
|
||||||
|
'method_chaining_indentation' => true,
|
||||||
|
'modernize_types_casting' => true,
|
||||||
|
'multiline_comment_opening_closing' => true,
|
||||||
|
'native_function_casing' => true,
|
||||||
|
'native_function_type_declaration_casing' => true,
|
||||||
|
'new_with_braces' => true,
|
||||||
|
'no_blank_lines_after_class_opening' => true,
|
||||||
|
'no_blank_lines_after_phpdoc' => true,
|
||||||
|
'no_empty_comment' => true,
|
||||||
|
'no_empty_phpdoc' => true,
|
||||||
|
'no_empty_statement' => true,
|
||||||
|
'no_leading_namespace_whitespace' => true,
|
||||||
|
'no_mixed_echo_print' => ['use' => 'echo'],
|
||||||
|
'no_unneeded_control_parentheses' => true,
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'no_useless_return' => true,
|
||||||
|
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||||
|
// 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], // adds params in the wrong order
|
||||||
|
'phpdoc_align' => ['align' => 'left'],
|
||||||
|
'phpdoc_indent' => true,
|
||||||
|
'phpdoc_scalar' => true,
|
||||||
|
'phpdoc_trim' => true,
|
||||||
|
'short_scalar_cast' => true,
|
||||||
|
'single_line_comment_style' => true,
|
||||||
|
'single_quote' => true,
|
||||||
|
'ternary_to_null_coalescing' => true,
|
||||||
|
'whitespace_after_comma_in_array' => true
|
||||||
|
])
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setIndent("\t")
|
||||||
|
->setFinder($finder);
|
||||||
21
site/plugins/kirby-seo/LICENSE
Normal file
21
site/plugins/kirby-seo/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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.
|
||||||
30
site/plugins/kirby-seo/README.md
Normal file
30
site/plugins/kirby-seo/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|

|
||||||
|
|
||||||
|
<h1 align="center">Kirby SEO</h1>
|
||||||
|
<p align="center">All-in-one toolkit that makes implementing SEO & Meta best practices a breeze</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
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
[Read the documentation](https://plugins.andkindness.com/seo/docs/get-started/feature-overview) to get started with Kirby SEO.
|
||||||
|
|
||||||
|
## Support the project
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This plugin is provided free of charge & published under the permissive MIT License. If you use it in a commercial project, please consider sponsoring me on GitHub to support further development and continued maintenance of Kirby SEO.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](./../LICENSE)
|
||||||
|
Copyright © 2023-2024 Tobias Möritz
|
||||||
33
site/plugins/kirby-seo/blueprints/fields/meta-group.yml
Normal file
33
site/plugins/kirby-seo/blueprints/fields/meta-group.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
type: group
|
||||||
|
fields:
|
||||||
|
metaHeadline:
|
||||||
|
label: meta-headline
|
||||||
|
type: headline
|
||||||
|
numbered: false
|
||||||
|
metaTitle:
|
||||||
|
label: title-overwrite
|
||||||
|
type: text
|
||||||
|
placeholder: '{{ page.title }}'
|
||||||
|
metaTemplate:
|
||||||
|
label: meta-title-template
|
||||||
|
type: text
|
||||||
|
help: meta-title-template-help
|
||||||
|
width: 2/3
|
||||||
|
placeholder: '{{ page.metadata.metaTemplate }}'
|
||||||
|
useTitleTemplate:
|
||||||
|
label: use-title-template
|
||||||
|
type: toggle
|
||||||
|
help: use-title-template-help
|
||||||
|
width: 1/3
|
||||||
|
default: true
|
||||||
|
text:
|
||||||
|
- "{{ t('use-title-template-no') }}"
|
||||||
|
- "{{ t('use-title-template-yes') }}"
|
||||||
|
metaDescription:
|
||||||
|
label: meta-description
|
||||||
|
type: textarea
|
||||||
|
help: meta-description-help
|
||||||
|
placeholder: '{{ page.metadata.metaDescription }}'
|
||||||
|
buttons: false
|
||||||
|
seoLine1:
|
||||||
|
type: line
|
||||||
48
site/plugins/kirby-seo/blueprints/fields/og-group.yml
Normal file
48
site/plugins/kirby-seo/blueprints/fields/og-group.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
type: group
|
||||||
|
fields:
|
||||||
|
ogHeadline:
|
||||||
|
label: og-headline
|
||||||
|
type: headline
|
||||||
|
numbered: false
|
||||||
|
help: global-og-headline-help
|
||||||
|
ogTemplate:
|
||||||
|
label: og-title-template
|
||||||
|
type: text
|
||||||
|
width: 2/3
|
||||||
|
help: meta-title-template-help
|
||||||
|
placeholder: '{{ page.metadata.ogTemplate }}'
|
||||||
|
useOgTemplate:
|
||||||
|
label: use-title-template
|
||||||
|
type: toggle
|
||||||
|
help: use-title-template-help
|
||||||
|
width: 1/3
|
||||||
|
default: true
|
||||||
|
text:
|
||||||
|
- "{{ t('use-title-template-no') }}"
|
||||||
|
- "{{ t('use-title-template-yes') }}"
|
||||||
|
ogDescription:
|
||||||
|
label: og-description
|
||||||
|
type: textarea
|
||||||
|
buttons: false
|
||||||
|
placeholder: '{{ page.metadata.ogDescription }}'
|
||||||
|
ogImage:
|
||||||
|
label: og-image
|
||||||
|
extends: seo/fields/og-image
|
||||||
|
empty: og-image-empty
|
||||||
|
twitterCardType:
|
||||||
|
label: twitter-card-type
|
||||||
|
width: 1/2
|
||||||
|
placeholder: "{{ t('default-select') }} {{ t(site.twitterCardType) }}"
|
||||||
|
type: select
|
||||||
|
options:
|
||||||
|
summary: "{{ t('summary') }}"
|
||||||
|
summary_large_image: "{{ t('summary_large_image') }}"
|
||||||
|
help: twitter-card-type-help
|
||||||
|
twitterAuthor:
|
||||||
|
label: twitter-author
|
||||||
|
width: 1/2
|
||||||
|
type: text
|
||||||
|
before: '@'
|
||||||
|
placeholder: '{{ page.metadata.twitterCreator }}'
|
||||||
|
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' => Str::contains($kirby->path(), '/site') && !Str::contains($kirby->path(), 'pages') ? 'site.images' : 'page.images' // small hack to get context for field using api path
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
55
site/plugins/kirby-seo/blueprints/fields/robots.php
Normal file
55
site/plugins/kirby-seo/blueprints/fields/robots.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Cms\App;
|
||||||
|
use Kirby\Toolkit\A;
|
||||||
|
use Kirby\Toolkit\Str;
|
||||||
|
use tobimori\Seo\Meta;
|
||||||
|
|
||||||
|
return function (App $kirby) {
|
||||||
|
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
|
||||||
|
return [
|
||||||
|
'type' => 'hidden'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'robotsHeadline' => [
|
||||||
|
'label' => 'robots',
|
||||||
|
'type' => 'headline',
|
||||||
|
'numbered' => false,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$page = Meta::currentPage();
|
||||||
|
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
|
||||||
|
$upper = Str::ucfirst($robots);
|
||||||
|
|
||||||
|
$fields["robots{$upper}"] = [
|
||||||
|
'label' => "robots-{$robots}",
|
||||||
|
'type' => 'toggles',
|
||||||
|
'help' => "robots-{$robots}-help",
|
||||||
|
'width' => '1/2',
|
||||||
|
'default' => 'default',
|
||||||
|
'reset' => false,
|
||||||
|
'options' => [
|
||||||
|
'default' => $page ?
|
||||||
|
A::join([
|
||||||
|
t('default-select'),
|
||||||
|
$page->metadata()->get("robots{$upper}", ['fields'])->toBool() ? t('yes') : t('no')
|
||||||
|
], ' ')
|
||||||
|
: t('default-select'),
|
||||||
|
'true' => t('yes'),
|
||||||
|
'false' => t('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' => 'robots',
|
||||||
|
'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' => "robots-{$robots}",
|
||||||
|
'type' => 'toggles',
|
||||||
|
'help' => "robots-{$robots}-help",
|
||||||
|
'width' => '1/2',
|
||||||
|
'default' => 'default',
|
||||||
|
'reset' => false,
|
||||||
|
'options' => [
|
||||||
|
'default' => t('default-select') . ' ' . ($index ? t('yes') : t('no')),
|
||||||
|
'true' => t('yes'),
|
||||||
|
'false' => t('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 Kirby\Cms\App;
|
||||||
|
|
||||||
|
return function (App $kirby) {
|
||||||
|
$fields = [];
|
||||||
|
|
||||||
|
foreach ($kirby->option('tobimori.seo.socialMedia') as $key => $value) {
|
||||||
|
if ($value) {
|
||||||
|
$fields[$key] = [
|
||||||
|
'label' => ucfirst($key),
|
||||||
|
'type' => 'url',
|
||||||
|
'icon' => strtolower($key),
|
||||||
|
'placeholder' => $value
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => 'social-media-accounts',
|
||||||
|
'type' => 'object',
|
||||||
|
'help' => 'social-media-accounts-help',
|
||||||
|
'fields' => $fields
|
||||||
|
];
|
||||||
|
};
|
||||||
29
site/plugins/kirby-seo/blueprints/page.yml
Normal file
29
site/plugins/kirby-seo/blueprints/page.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
label: metadata-site
|
||||||
|
icon: search
|
||||||
|
|
||||||
|
columns:
|
||||||
|
main:
|
||||||
|
width: 7/12
|
||||||
|
fields:
|
||||||
|
metaGroup: seo/fields/meta-group
|
||||||
|
ogGroup: seo/fields/og-group
|
||||||
|
robots: seo/fields/robots
|
||||||
|
metaInherit:
|
||||||
|
label: inherit-settings
|
||||||
|
type: multiselect
|
||||||
|
help: inherit-settings-help
|
||||||
|
options:
|
||||||
|
metaTemplate: "{{ t('meta-title-template') }}"
|
||||||
|
metaDescription: "{{ t('meta-description') }}"
|
||||||
|
ogTemplate: "{{ t('og-title-template') }}"
|
||||||
|
ogDescription: "{{ t('og-description') }}"
|
||||||
|
ogImage: "{{ t('og-image') }}"
|
||||||
|
twitterCardType: "{{ t('twitter-card-type') }}"
|
||||||
|
twitterAuthor: "{{ t('twitter-author') }}"
|
||||||
|
robots: '{{ t("robots") }}'
|
||||||
|
sidebar:
|
||||||
|
width: 5/12
|
||||||
|
sticky: true
|
||||||
|
sections:
|
||||||
|
seoPreview:
|
||||||
|
type: seo-preview
|
||||||
70
site/plugins/kirby-seo/blueprints/site.yml
Normal file
70
site/plugins/kirby-seo/blueprints/site.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
label: metadata-site
|
||||||
|
icon: search
|
||||||
|
|
||||||
|
columns:
|
||||||
|
main:
|
||||||
|
width: 7/12
|
||||||
|
fields:
|
||||||
|
metaHeadline:
|
||||||
|
label: global-meta-headline
|
||||||
|
type: headline
|
||||||
|
numbered: false
|
||||||
|
help: global-meta-headline-help
|
||||||
|
metaTemplate:
|
||||||
|
label: meta-title-template
|
||||||
|
type: text
|
||||||
|
help: meta-title-template-help
|
||||||
|
metaDescription:
|
||||||
|
label: meta-description
|
||||||
|
type: textarea
|
||||||
|
help: meta-description-help
|
||||||
|
buttons: false
|
||||||
|
seoLine1:
|
||||||
|
type: line
|
||||||
|
ogHeadline:
|
||||||
|
label: global-og-headline
|
||||||
|
type: headline
|
||||||
|
numbered: false
|
||||||
|
help: global-og-headline-help
|
||||||
|
ogTemplate:
|
||||||
|
label: og-title-template
|
||||||
|
type: text
|
||||||
|
default: '{{ title }}'
|
||||||
|
help: meta-title-template-help
|
||||||
|
placeholder: '{{ site.metaTemplate }}'
|
||||||
|
ogDescription:
|
||||||
|
label: og-description
|
||||||
|
type: textarea
|
||||||
|
buttons: false
|
||||||
|
placeholder: '{{ site.metaDescription }}'
|
||||||
|
ogSiteName:
|
||||||
|
label: og-site-name
|
||||||
|
type: text
|
||||||
|
default: '{{ site.title }}'
|
||||||
|
placeholder: '{{ site.title }}'
|
||||||
|
width: 1/2
|
||||||
|
ogImage:
|
||||||
|
label: og-image
|
||||||
|
extends: seo/fields/og-image
|
||||||
|
empty: og-image-empty
|
||||||
|
width: 1/2
|
||||||
|
twitterCardType:
|
||||||
|
label: twitter-card-type
|
||||||
|
width: 1/2
|
||||||
|
type: select
|
||||||
|
default: summary
|
||||||
|
required: true
|
||||||
|
options:
|
||||||
|
summary: "{{ t('summary') }}"
|
||||||
|
summary_large_image: "{{ t('summary_large_image') }}"
|
||||||
|
help: twitter-card-type-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
|
||||||
670
site/plugins/kirby-seo/classes/Meta.php
Normal file
670
site/plugins/kirby-seo/classes/Meta.php
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace tobimori\Seo;
|
||||||
|
|
||||||
|
use Kirby\Cms\App;
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Kirby\Content\Field;
|
||||||
|
use Kirby\Exception\InvalidArgumentException;
|
||||||
|
use Kirby\Toolkit\A;
|
||||||
|
use Kirby\Toolkit\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Meta class is responsible for handling the meta data & cascading
|
||||||
|
*/
|
||||||
|
class Meta
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* These values will be handled as 'field is empty'
|
||||||
|
*/
|
||||||
|
public const DEFAULT_VALUES = ['[]', 'default'];
|
||||||
|
|
||||||
|
protected Page $page;
|
||||||
|
protected ?string $lang;
|
||||||
|
protected array $consumed = [];
|
||||||
|
protected array $metaDefaults = [];
|
||||||
|
protected array $metaArray = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Meta instance
|
||||||
|
*/
|
||||||
|
public function __construct(Page $page, ?string $lang = null)
|
||||||
|
{
|
||||||
|
$this->page = $page;
|
||||||
|
$this->lang = $lang;
|
||||||
|
|
||||||
|
if (method_exists($this->page, 'metaDefaults')) {
|
||||||
|
$this->metaDefaults = $this->page->metaDefaults($this->lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the meta array which maps meta tags to their fieldnames
|
||||||
|
*/
|
||||||
|
protected function metaArray(): array
|
||||||
|
{
|
||||||
|
if ($this->metaArray) {
|
||||||
|
return $this->metaArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We have to specify field names and resolve them later, so we can use this
|
||||||
|
* function to resolve meta tags from field names in the programmatic function
|
||||||
|
*/
|
||||||
|
$meta =
|
||||||
|
[
|
||||||
|
'title' => 'metaTitle',
|
||||||
|
'description' => 'metaDescription',
|
||||||
|
'date' => fn () => $this->page->modified($this->dateFormat()),
|
||||||
|
'og:title' => 'ogTitle',
|
||||||
|
'og:description' => 'ogDescription',
|
||||||
|
'og:site_name' => 'ogSiteName',
|
||||||
|
'og:image' => 'ogImage',
|
||||||
|
'og:image:width' => fn () => $this->ogImage() ? $this->get('ogImage')->toFile()?->width() : null,
|
||||||
|
'og:image:height' => fn () => $this->ogImage() ? $this->get('ogImage')->toFile()?->height() : null,
|
||||||
|
'og:image:alt' => fn () => $this->get('ogImage')->toFile()?->alt(),
|
||||||
|
'og:type' => 'ogType',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// Robots
|
||||||
|
if ($robotsActive = option('tobimori.seo.robots.active')) {
|
||||||
|
$meta['robots'] = fn () => $this->robots();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only add canonical and alternate tags if the page is indexable
|
||||||
|
// we have to resolve this lazily (using a callable) to avoid an infinite loop
|
||||||
|
$allowsIndexFn = fn () => !$robotsActive || !Str::contains($this->robots(), 'noindex');
|
||||||
|
|
||||||
|
// canonical
|
||||||
|
$canonicalFn = fn () => $allowsIndexFn() ? $this->canonicalUrl() : null;
|
||||||
|
$meta['canonical'] = $canonicalFn;
|
||||||
|
$meta['og:url'] = $canonicalFn;
|
||||||
|
|
||||||
|
// Multi-lang alternate tags
|
||||||
|
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
|
||||||
|
foreach (kirby()->languages() as $lang) {
|
||||||
|
// only add alternate tags if the page is indexable
|
||||||
|
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
|
||||||
|
'hreflang' => $lang->code(),
|
||||||
|
'href' => $this->page->url($lang->code()),
|
||||||
|
] : null;
|
||||||
|
|
||||||
|
if ($lang !== kirby()->language()) {
|
||||||
|
$meta['og:locale:alternate'][] = fn () => $lang->code();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// only add alternate tags if the page is indexable
|
||||||
|
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
|
||||||
|
'hreflang' => 'x-default',
|
||||||
|
'href' => $this->page->url(kirby()->language()->code()),
|
||||||
|
] : null;
|
||||||
|
$meta['og:locale'] = fn () => kirby()->language()->locale(LC_ALL);
|
||||||
|
} else {
|
||||||
|
$meta['og:locale'] = fn () => $this->locale(LC_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitter tags "opt-in" - TODO: wip
|
||||||
|
if (option('tobimori.seo.twitter', true)) {
|
||||||
|
$meta = array_merge($meta, [
|
||||||
|
'twitter:card' => 'twitterCardType',
|
||||||
|
'twitter:title' => 'ogTitle',
|
||||||
|
'twitter:description' => 'ogDescription',
|
||||||
|
'twitter:image' => 'ogImage',
|
||||||
|
'twitter:site' => 'twitterSite',
|
||||||
|
'twitter:creator' => 'twitterCreator',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This array will be normalized for use in the snippet in $this->snippetData()
|
||||||
|
return $this->metaArray = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This array defines what HTML tag the corresponding meta tags are using including the attributes,
|
||||||
|
* so everything is a bit more elegant when defining programmatic content (supports regex)
|
||||||
|
*/
|
||||||
|
public const TAG_TYPE_MAP = [
|
||||||
|
[
|
||||||
|
'tag' => 'title',
|
||||||
|
'tags' => [
|
||||||
|
'title'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tag' => 'link',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'rel',
|
||||||
|
'content' => 'href',
|
||||||
|
],
|
||||||
|
'tags' => [
|
||||||
|
'canonical',
|
||||||
|
'alternate',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tag' => 'meta',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'property',
|
||||||
|
'content' => 'content',
|
||||||
|
],
|
||||||
|
'tags' => [
|
||||||
|
'/og:.+/'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the meta array and remaining meta defaults to be used in the snippet,
|
||||||
|
* also resolve the content, if necessary
|
||||||
|
*/
|
||||||
|
public function snippetData(array $raw = null): array
|
||||||
|
{
|
||||||
|
$mergeWithDefaults = !isset($raw);
|
||||||
|
$raw ??= $this->metaArray();
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
foreach ($raw as $name => $value) {
|
||||||
|
// if the key is numeric, it is already normalized to the correct array syntax
|
||||||
|
if (is_numeric($name)) {
|
||||||
|
// but we still check if the array is valid
|
||||||
|
if (!is_array($value) || count(array_intersect(['tag', 'content', 'attributes'], array_keys($value))) !== count($value)) {
|
||||||
|
throw new InvalidArgumentException("[kirby-seo] Invalid array structure found in programmatic content for page {$this->slug()}. Please check your metaDefaults method for template {$this->template()->name()}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags[] = $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow overrides from metaDefaults for keys that are a callable or array by default
|
||||||
|
// (all fields from meta array that are not part of the regular cascade)
|
||||||
|
if ((is_callable($value) || is_array($value)) && $mergeWithDefaults && array_key_exists($name, $this->metaDefaults)) {
|
||||||
|
$this->consumed[] = $name;
|
||||||
|
$value = $this->metaDefaults[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the value is a string, we know it's a field name
|
||||||
|
if (is_string($value)) {
|
||||||
|
$value = $this->$value($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the value is a callable, we resolve it
|
||||||
|
if (is_callable($value)) {
|
||||||
|
$value = $value($this->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the value is empty, we don't want to output it
|
||||||
|
if ((is_a($value, 'Kirby\Content\Field') && $value->isEmpty()) || !$value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve the tag type from the meta array
|
||||||
|
// so we can use the correct attributes to normalize it
|
||||||
|
$tag = $this->resolveTag($name);
|
||||||
|
|
||||||
|
// if the value is an associative array now, all of them are attributes
|
||||||
|
// and we don't look for what the TAG_TYPE_MAP says
|
||||||
|
// or there should be multiple tags with the same name (non-associative array)
|
||||||
|
if (is_array($value)) {
|
||||||
|
if (!A::isAssociative($value)) {
|
||||||
|
foreach ($value as $val) {
|
||||||
|
$tags = array_merge($tags, $this->snippetData([$name => $val]));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// array is associative, so it's an array of attributes
|
||||||
|
// we resolve the values, if they are callable
|
||||||
|
array_walk($value, function (&$item) {
|
||||||
|
if (is_callable($item)) {
|
||||||
|
$item = $item($this->page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// add the tag to the array
|
||||||
|
$tags[] = [
|
||||||
|
'tag' => $tag['tag'],
|
||||||
|
'attributes' => $value,
|
||||||
|
'content' => null,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the value is a string, we use the TAG_TYPE_MAP
|
||||||
|
// to correctly map the attributes
|
||||||
|
$tags[] = [
|
||||||
|
'tag' => $tag['tag'],
|
||||||
|
'attributes' => isset($tag['attributes']) ? [
|
||||||
|
$tag['attributes']['name'] => $name,
|
||||||
|
$tag['attributes']['content'] => $value,
|
||||||
|
] : null,
|
||||||
|
'content' => !isset($tag['attributes']) ? $value : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mergeWithDefaults) {
|
||||||
|
// merge the remaining meta defaults
|
||||||
|
$tags = array_merge($tags, $this->snippetData(array_diff_key($this->metaDefaults, array_flip($this->consumed))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the tag type from the meta array
|
||||||
|
*/
|
||||||
|
protected function resolveTag(string $tag): array
|
||||||
|
{
|
||||||
|
foreach (self::TAG_TYPE_MAP as $type) {
|
||||||
|
foreach ($type['tags'] as $regexOrString) {
|
||||||
|
// Check if the supplied tag is a regex or a normal tag name
|
||||||
|
if (Str::startsWith($regexOrString, '/') && Str::endsWith($regexOrString, '/') ?
|
||||||
|
Str::match($tag, $regexOrString) : $tag === $regexOrString
|
||||||
|
) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tag' => 'meta',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'name',
|
||||||
|
'content' => 'content',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic method to get a meta value by calling the method name
|
||||||
|
*/
|
||||||
|
public function __call($name, $args = null): mixed
|
||||||
|
{
|
||||||
|
if (method_exists($this, $name)) {
|
||||||
|
return $this->$name($args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->get($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key
|
||||||
|
*/
|
||||||
|
public function get(string $key, array $exclude = []): Field
|
||||||
|
{
|
||||||
|
$cascade = option('tobimori.seo.cascade');
|
||||||
|
if (count(array_intersect(get_class_methods($this), $cascade)) !== count($cascade)) {
|
||||||
|
throw new InvalidArgumentException('[kirby-seo] Invalid cascade method in config. Please check your options for `tobimori.seo.cascade`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track consumed keys, so we don't output legacy field values
|
||||||
|
$toBeConsumed = $key;
|
||||||
|
if (
|
||||||
|
(array_key_exists($toBeConsumed, $this->metaDefaults)
|
||||||
|
|| array_key_exists($toBeConsumed = $this->findTagForField($toBeConsumed), $this->metaDefaults))
|
||||||
|
&& !in_array($toBeConsumed, $this->consumed)
|
||||||
|
) {
|
||||||
|
$this->consumed[] = $toBeConsumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_diff($cascade, $exclude) as $method) {
|
||||||
|
if ($field = $this->$method($key)) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field($this->page, $key, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key from the page's fields
|
||||||
|
*/
|
||||||
|
protected function fields(string $key): Field|null
|
||||||
|
{
|
||||||
|
if (($field = $this->page->content($this->lang)->get($key))) {
|
||||||
|
if (Str::contains($key, 'robots') && !option('tobimori.seo.robots.pageSettings')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $field->value())) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Open Graph fields to Meta fields for fallbackFields
|
||||||
|
* cascade method
|
||||||
|
*/
|
||||||
|
public const FALLBACK_MAP = [
|
||||||
|
'ogTitle' => 'metaTitle',
|
||||||
|
'ogDescription' => 'metaDescription',
|
||||||
|
'ogTemplate' => 'metaTemplate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We only allow the following cascade methods for fallbacks,
|
||||||
|
* because we don't want to fallback to the config defaults for
|
||||||
|
* Meta fields, because we most likely already have those set
|
||||||
|
* for the Open Graph fields
|
||||||
|
*/
|
||||||
|
public const FALLBACK_CASCADE = [
|
||||||
|
'fields',
|
||||||
|
'programmatic',
|
||||||
|
'parent',
|
||||||
|
'site'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key using the fallback fields
|
||||||
|
* defined above (usually Open Graph > Meta Fields)
|
||||||
|
*/
|
||||||
|
protected function fallbackFields(string $key): Field|null
|
||||||
|
{
|
||||||
|
if (array_key_exists($key, self::FALLBACK_MAP)) {
|
||||||
|
$fallback = self::FALLBACK_MAP[$key];
|
||||||
|
$cascade = option('tobimori.seo.cascade');
|
||||||
|
|
||||||
|
foreach (array_intersect($cascade, self::FALLBACK_CASCADE) as $method) {
|
||||||
|
if ($field = $this->$method($fallback)) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findTagForField(string $fieldName): string|null
|
||||||
|
{
|
||||||
|
return array_search($fieldName, $this->metaArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key from the page's meta
|
||||||
|
* array, which can be set in the page's model metaDefaults method
|
||||||
|
*/
|
||||||
|
protected function programmatic(string $key): Field|null
|
||||||
|
{
|
||||||
|
if (!$this->metaDefaults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the key (field name) is in the array syntax
|
||||||
|
if (array_key_exists($key, $this->metaDefaults)) {
|
||||||
|
$val = $this->metaDefaults[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If there is no programmatic value for the key,
|
||||||
|
* try looking it up in the meta array
|
||||||
|
* maybe it is a meta tag and not a field name?
|
||||||
|
*/
|
||||||
|
if (!isset($val) && ($key = $this->findTagForField($key)) && array_key_exists($key, $this->metaDefaults)) {
|
||||||
|
$val = $this->metaDefaults[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($val)) {
|
||||||
|
if (is_callable($val)) {
|
||||||
|
$val = $val($this->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($val)) {
|
||||||
|
$val = $val['content'] ?? $val['href'] ?? null;
|
||||||
|
|
||||||
|
// Last sanity check, if the array syntax doesn't have a supported key
|
||||||
|
if ($val === null) {
|
||||||
|
// Remove the key from the consumed array, so it doesn't get filtered out
|
||||||
|
// (we can assume the entry is a custom meta tag that uses different attributes)
|
||||||
|
$this->consumed = array_filter($this->consumed, fn ($item) => $item !== $key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_a($val, 'Kirby\Content\Field')) {
|
||||||
|
return new Field($this->page, $key, $val->value());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field($this->page, $key, $val);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key from the page's parent,
|
||||||
|
* if the page is allowed to inherit the value
|
||||||
|
*/
|
||||||
|
protected function parent(string $key): Field|null
|
||||||
|
{
|
||||||
|
if ($this->canInherit($key)) {
|
||||||
|
$parent = $this->page->parent();
|
||||||
|
$parentMeta = new Meta($parent, $this->lang);
|
||||||
|
if ($value = $parentMeta->get($key)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key from the
|
||||||
|
* site's meta blueprint & content
|
||||||
|
*/
|
||||||
|
protected function site(string $key): Field|null
|
||||||
|
{
|
||||||
|
if (($site = $this->page->site()->content($this->lang)->get($key)) && ($site->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $site->value))) {
|
||||||
|
return $site;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the meta value for a given key from the
|
||||||
|
* config.php options
|
||||||
|
*/
|
||||||
|
protected function options(string $key): Field|null
|
||||||
|
{
|
||||||
|
if ($option = option("tobimori.seo.default.{$key}")) {
|
||||||
|
if (is_callable($option)) {
|
||||||
|
$option = $option($this->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_a($option, 'Kirby\Content\Field')) {
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field($this->page, $key, $option);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the page can inherit a meta value from its parent
|
||||||
|
*/
|
||||||
|
private function canInherit(string $key): bool
|
||||||
|
{
|
||||||
|
$parent = $this->page->parent();
|
||||||
|
if (!$parent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inherit = $parent->metaInherit()->split();
|
||||||
|
if (Str::contains($key, 'robots') && A::has($inherit, 'robots')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return A::has($inherit, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the title template, and returns the correct title
|
||||||
|
*/
|
||||||
|
public function metaTitle()
|
||||||
|
{
|
||||||
|
$title = $this->get('metaTitle');
|
||||||
|
$template = $this->get('metaTemplate');
|
||||||
|
|
||||||
|
$useTemplate = $this->page->useTitleTemplate();
|
||||||
|
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
|
||||||
|
|
||||||
|
$string = $title->value();
|
||||||
|
if ($useTemplate) {
|
||||||
|
$string = $this->page->toString(
|
||||||
|
$template,
|
||||||
|
['title' => $title]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field(
|
||||||
|
$this->page,
|
||||||
|
'metaTitle',
|
||||||
|
$string
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the OG title template, and returns the OG Title
|
||||||
|
*/
|
||||||
|
public function ogTitle()
|
||||||
|
{
|
||||||
|
$title = $this->get('metaTitle');
|
||||||
|
$template = $this->get('ogTemplate');
|
||||||
|
|
||||||
|
$useTemplate = $this->page->useOgTemplate();
|
||||||
|
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
|
||||||
|
|
||||||
|
$string = $title->value();
|
||||||
|
if ($useTemplate) {
|
||||||
|
$string = $this->page->toString(
|
||||||
|
$template,
|
||||||
|
['title' => $title]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field(
|
||||||
|
$this->page,
|
||||||
|
'ogTitle',
|
||||||
|
$string
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the canonical url for the page
|
||||||
|
*/
|
||||||
|
public function canonicalUrl()
|
||||||
|
{
|
||||||
|
return $this->page->site()->canonicalFor($this->page->url());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Twitter username from an account url set in the site options
|
||||||
|
*/
|
||||||
|
public function twitterSite()
|
||||||
|
{
|
||||||
|
$accs = $this->page->site()->socialMediaAccounts()->toObject();
|
||||||
|
$username = '';
|
||||||
|
|
||||||
|
if ($accs->twitter()->isNotEmpty()) {
|
||||||
|
// tries to match all twitter urls, and extract the username
|
||||||
|
$matches = [];
|
||||||
|
preg_match('/^(https?:\/\/)?(www\.)?twitter\.com\/(#!\/)?@?(?<name>[^\/\?]*)$/', $accs->twitter()->value(), $matches);
|
||||||
|
if (isset($matches['name'])) {
|
||||||
|
$username = $matches['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Field($this->page, 'twitter', $username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the date format for modified meta tags, based on the registered date handler
|
||||||
|
*/
|
||||||
|
public function dateFormat(): string
|
||||||
|
{
|
||||||
|
if ($custom = option('tobimori.seo.dateFormat')) {
|
||||||
|
if (is_callable($custom)) {
|
||||||
|
return $custom($this->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (option('date.handler')) {
|
||||||
|
case 'strftime':
|
||||||
|
return '%Y-%m-%d';
|
||||||
|
case 'intl':
|
||||||
|
return 'yyyy-MM-dd';
|
||||||
|
case 'date':
|
||||||
|
default:
|
||||||
|
return 'Y-m-d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pages' robots rules as string
|
||||||
|
*/
|
||||||
|
public function robots()
|
||||||
|
{
|
||||||
|
$robots = [];
|
||||||
|
foreach (option('tobimori.seo.robots.types') as $type) {
|
||||||
|
if (!$this->get('robots' . Str::ucfirst($type))->toBool()) {
|
||||||
|
$robots[] = 'no' . Str::lower($type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (A::count($robots) === 0) {
|
||||||
|
$robots = ['all'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return A::join($robots, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the og:image url
|
||||||
|
*/
|
||||||
|
public function ogImage(): string|null
|
||||||
|
{
|
||||||
|
$field = $this->get('ogImage');
|
||||||
|
|
||||||
|
if ($ogImage = $field->toFile()?->thumb([
|
||||||
|
'width' => 1200,
|
||||||
|
'height' => 630,
|
||||||
|
'crop' => true,
|
||||||
|
])) {
|
||||||
|
return $ogImage->url();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field->isNotEmpty()) {
|
||||||
|
return $field->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method the get the current page from the URL path,
|
||||||
|
* for use in programmatic blueprints
|
||||||
|
*/
|
||||||
|
public static function currentPage(): Page|null
|
||||||
|
{
|
||||||
|
$path = App::instance()->request()->url()->toString();
|
||||||
|
$matches = Str::match($path, "/pages\/([a-zA-Z0-9-_+]+)\/?/m");
|
||||||
|
$segments = Str::split($matches[1], '+');
|
||||||
|
|
||||||
|
$page = App::instance()->site();
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if ($page = $page->findPageOrDraft($segment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
site/plugins/kirby-seo/classes/SchemaSingleton.php
Normal file
29
site/plugins/kirby-seo/classes/SchemaSingleton.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace tobimori\Seo;
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Spatie\SchemaOrg\Schema;
|
||||||
|
|
||||||
|
class SchemaSingleton
|
||||||
|
{
|
||||||
|
private static $instances = [];
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getInstance(string $type, Page|null $page = null): mixed
|
||||||
|
{
|
||||||
|
if (!isset(self::$instances[$page?->id() ?? 'default'][$type])) {
|
||||||
|
self::$instances[$page?->id() ?? 'default'][$type] = Schema::{$type}();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instances[$page?->id() ?? 'default'][$type];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getInstances(Page|null $page = null): array
|
||||||
|
{
|
||||||
|
return self::$instances[$page?->id() ?? 'default'] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
82
site/plugins/kirby-seo/classes/Sitemap/Sitemap.php
Normal file
82
site/plugins/kirby-seo/classes/Sitemap/Sitemap.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace tobimori\Seo\Sitemap;
|
||||||
|
|
||||||
|
use DOMDocument;
|
||||||
|
use Kirby\Cms\App;
|
||||||
|
use Kirby\Toolkit\Collection;
|
||||||
|
|
||||||
|
class Sitemap extends Collection
|
||||||
|
{
|
||||||
|
public function __construct(protected string $key, array $data = [], bool $caseSensitive = false)
|
||||||
|
{
|
||||||
|
parent::__construct($data, $caseSensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function key(): string
|
||||||
|
{
|
||||||
|
return $this->key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loc(): string
|
||||||
|
{
|
||||||
|
return kirby()->site()->canonicalFor('sitemap-' . $this->key . '.xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastmod(): string
|
||||||
|
{
|
||||||
|
$lastmod = 0;
|
||||||
|
foreach ($this as $url) {
|
||||||
|
$lastmod = max($lastmod, strtotime($url->lastmod()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastmod > 0) {
|
||||||
|
return date('c', $lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUrl(string $loc): SitemapUrl
|
||||||
|
{
|
||||||
|
$url = $this->makeUrl($loc);
|
||||||
|
$this->append($url);
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function makeUrl(string $url): SitemapUrl
|
||||||
|
{
|
||||||
|
return new SitemapUrl($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8'))
|
||||||
|
{
|
||||||
|
$doc->formatOutput = true;
|
||||||
|
|
||||||
|
$root = $doc->createElement('sitemap');
|
||||||
|
foreach (['loc', 'lastmod'] as $key) {
|
||||||
|
$root->appendChild($doc->createElement($key, $this->$key()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||||
|
$doc->formatOutput = true;
|
||||||
|
|
||||||
|
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="/sitemap.xsl"'));
|
||||||
|
|
||||||
|
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'urlset');
|
||||||
|
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
|
||||||
|
$root->setAttribute('seo-version', App::plugin('tobimori/seo')->version());
|
||||||
|
|
||||||
|
foreach ($this as $url) {
|
||||||
|
$root->appendChild($url->toDOMNode($doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc->appendChild($root);
|
||||||
|
return $doc->saveXML();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
site/plugins/kirby-seo/classes/Sitemap/SitemapIndex.php
Normal file
100
site/plugins/kirby-seo/classes/Sitemap/SitemapIndex.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace tobimori\Seo\Sitemap;
|
||||||
|
|
||||||
|
use DOMDocument;
|
||||||
|
use Kirby\Cms\App;
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Kirby\Toolkit\Collection;
|
||||||
|
|
||||||
|
class SitemapIndex extends Collection
|
||||||
|
{
|
||||||
|
protected static $instance = null;
|
||||||
|
|
||||||
|
public static function instance(...$args): static
|
||||||
|
{
|
||||||
|
if (static::$instance === null) {
|
||||||
|
static::$instance = new static(...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $key = 'pages'): Sitemap
|
||||||
|
{
|
||||||
|
$sitemap = $this->make($key);
|
||||||
|
$this->append($sitemap);
|
||||||
|
return $sitemap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function make(string $key = 'pages'): Sitemap
|
||||||
|
{
|
||||||
|
return new Sitemap($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function makeUrl(string $url): SitemapUrl
|
||||||
|
{
|
||||||
|
return new SitemapUrl($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||||
|
$doc->formatOutput = true;
|
||||||
|
|
||||||
|
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="sitemap.xsl"'));
|
||||||
|
|
||||||
|
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'sitemapindex');
|
||||||
|
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
|
||||||
|
$root->setAttribute('seo-version', App::plugin('tobimori/seo')->version());
|
||||||
|
$doc->appendChild($root);
|
||||||
|
|
||||||
|
foreach ($this as $sitemap) {
|
||||||
|
$root->appendChild($sitemap->toDOMNode($doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $doc->saveXML();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidIndex(string $key = null): bool
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->count() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!$this->findBy('key', $key) && $this->count() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(): void
|
||||||
|
{
|
||||||
|
$generator = option('tobimori.seo.sitemap.generator');
|
||||||
|
if (is_callable($generator)) {
|
||||||
|
$generator($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(Page $page): string|null
|
||||||
|
{
|
||||||
|
// There always has to be at least one index,
|
||||||
|
// otherwise the sitemap will fail to render
|
||||||
|
if ($this->count() === 0) {
|
||||||
|
$this->generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->count() === 0) {
|
||||||
|
$this->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($index = $page->content()->get('index'))->isEmpty()) {
|
||||||
|
// If there is only one index, we do not need to render the index page
|
||||||
|
return $this->count() > 1 ? $this->toString() : $this->first()->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sitemap = $this->findBy('key', $index->value());
|
||||||
|
if ($sitemap) {
|
||||||
|
return $sitemap->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
site/plugins/kirby-seo/classes/Sitemap/SitemapUrl.php
Normal file
113
site/plugins/kirby-seo/classes/Sitemap/SitemapUrl.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace tobimori\Seo\Sitemap;
|
||||||
|
|
||||||
|
use DOMDocument;
|
||||||
|
use DOMNode;
|
||||||
|
use Kirby\Exception\Exception;
|
||||||
|
|
||||||
|
class SitemapUrl
|
||||||
|
{
|
||||||
|
protected string $lastmod;
|
||||||
|
protected string $changefreq;
|
||||||
|
protected string $priority;
|
||||||
|
protected array $alternates = [];
|
||||||
|
|
||||||
|
public function __construct(protected string $loc)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loc(string $url = null): SitemapUrl|string
|
||||||
|
{
|
||||||
|
if ($url === null) {
|
||||||
|
return $this->loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loc = $url;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastmod(string $lastmod = null): SitemapUrl|string
|
||||||
|
{
|
||||||
|
if ($lastmod === null) {
|
||||||
|
return $this->lastmod;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastmod = date('c', $lastmod);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changefreq(string $changefreq = null): SitemapUrl|string
|
||||||
|
{
|
||||||
|
if ($changefreq === null) {
|
||||||
|
return $this->changefreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->changefreq = $changefreq;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function priority(string $priority = null): SitemapUrl|string
|
||||||
|
{
|
||||||
|
if ($priority === null) {
|
||||||
|
return $this->priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->priority = $priority;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alternates(array $alternates = []): SitemapUrl|array
|
||||||
|
{
|
||||||
|
if (empty($alternates)) {
|
||||||
|
return $this->alternates;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($alternates as $alternate) {
|
||||||
|
foreach (['href', 'hreflang'] as $key) {
|
||||||
|
if (!array_key_exists($key, $alternate)) {
|
||||||
|
new Exception("[kirby-seo] The alternate link to '{$this->loc()} is missing the '{$key}' attribute");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$this->alternates = $alternates;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8')): DOMNode
|
||||||
|
{
|
||||||
|
$doc->formatOutput = true;
|
||||||
|
|
||||||
|
$node = $doc->createElement('url');
|
||||||
|
|
||||||
|
foreach (array_diff_key(get_object_vars($this), array_flip(['alternates'])) as $key => $value) {
|
||||||
|
$node->appendChild($doc->createElement($key, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->alternates())) {
|
||||||
|
foreach ($this->alternates() as $alternate) {
|
||||||
|
$alternateNode = $doc->createElement('xhtml:link');
|
||||||
|
foreach ($alternate as $key => $value) {
|
||||||
|
$alternateNode->setAttribute($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->appendChild($alternateNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||||
|
$doc->formatOutput = true;
|
||||||
|
|
||||||
|
$node = $this->toDOMNode();
|
||||||
|
$doc->appendChild($node);
|
||||||
|
|
||||||
|
return $doc->saveXML($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
site/plugins/kirby-seo/composer.json
Normal file
41
site/plugins/kirby-seo/composer.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "tobimori/kirby-seo",
|
||||||
|
"description": "The ultimate Kirby SEO toolkit",
|
||||||
|
"type": "kirby-plugin",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/tobimori/kirby-seo#readme",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tobias Möritz",
|
||||||
|
"email": "tobias@moeritz.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"tobimori\\Seo\\": "classes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1.0",
|
||||||
|
"getkirby/composer-installer": "^1.2.1",
|
||||||
|
"spatie/schema-org": "^3.14"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"getkirby/cms": "^4.0",
|
||||||
|
"getkirby/cli": "^1.2",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.48"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dist": "composer install --no-dev --optimize-autoloader"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"getkirby/composer-installer": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"kirby-cms-path": false
|
||||||
|
}
|
||||||
|
}
|
||||||
102
site/plugins/kirby-seo/config/api.php
Normal file
102
site/plugins/kirby-seo/config/api.php
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Kirby\Cms\Site;
|
||||||
|
use Kirby\Form\Form;
|
||||||
|
use Kirby\Toolkit\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => [
|
||||||
|
'dirtyPageOrSite' => function (string $slug) {
|
||||||
|
$kirby = kirby();
|
||||||
|
$page = $slug == 'site' ? $kirby->site() : $kirby->page(Str::replace($slug, '+', '/'));
|
||||||
|
|
||||||
|
if ($this->requestBody()) {
|
||||||
|
$form = Form::for($page, [ // Form class handles transformation of changed items
|
||||||
|
'ignoreDisabled' => true,
|
||||||
|
'input' => array_merge(['title' => $page->title()], $page->content()->data(), $this->requestBody()),
|
||||||
|
'language' => $kirby->language()?->code()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = $page->clone(['content' => $form->data()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'routes' => [
|
||||||
|
[
|
||||||
|
'pattern' => '/k-seo/(:any)/heading-structure',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function (string $slug) {
|
||||||
|
$model = $this->dirtyPageOrSite($slug);
|
||||||
|
|
||||||
|
if ($model instanceof Page) {
|
||||||
|
$page = $model->render();
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
$dom->loadHTML(htmlspecialchars_decode(mb_convert_encoding(htmlentities($page, ENT_COMPAT, 'UTF-8'), 'ISO-8859-1', 'UTF-8'), ENT_QUOTES), libxml_use_internal_errors(true));
|
||||||
|
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
foreach ($headings as $heading) {
|
||||||
|
$data[] = [
|
||||||
|
'level' => (int)str_replace('h', '', $heading->nodeName),
|
||||||
|
'text' => $heading->textContent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => '/k-seo/(:any)/seo-preview',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function (string $slug) {
|
||||||
|
$model = $this->dirtyPageOrSite($slug);
|
||||||
|
|
||||||
|
if ($model instanceof Site) {
|
||||||
|
$model = $model->homePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($model instanceof Page) {
|
||||||
|
$meta = $model->metadata();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page' => $model->slug(),
|
||||||
|
'url' => $model->url(),
|
||||||
|
'title' => $meta->metaTitle()->value(),
|
||||||
|
'description' => $meta->metaDescription()->value(),
|
||||||
|
'ogSiteName' => $meta->ogSiteName()->value(),
|
||||||
|
'ogTitle' => $meta->ogTitle()->value(),
|
||||||
|
'ogDescription' => $meta->ogDescription()->value(),
|
||||||
|
'ogImage' => $meta->ogImage(),
|
||||||
|
'twitterCardType' => $meta->twitterCardType()->value(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => '/k-seo/(:any)/robots',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function (string $slug) {
|
||||||
|
$model = $this->dirtyPageOrSite($slug);
|
||||||
|
if (!($model instanceof Page)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$robots = $model->robots();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => option('tobimori.seo.robots.indicator', option('tobimori.seo.robots.active', true)),
|
||||||
|
'state' => $robots,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
11
site/plugins/kirby-seo/config/commands/hello.php
Normal file
11
site/plugins/kirby-seo/config/commands/hello.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\CLI\CLI;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'description' => 'Hello world',
|
||||||
|
'args' => [],
|
||||||
|
'command' => static function (CLI $cli): void {
|
||||||
|
$cli->success('Hello world! This command is a preparation for a future release.');
|
||||||
|
}
|
||||||
|
];
|
||||||
27
site/plugins/kirby-seo/config/hooks.php
Normal file
27
site/plugins/kirby-seo/config/hooks.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Kirby\Toolkit\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page.update:after' => function (Page $newPage, Page $oldPage) {
|
||||||
|
foreach ($newPage->kirby()->option('tobimori.seo.robots.types') as $robots) {
|
||||||
|
$upper = Str::ucfirst($robots);
|
||||||
|
if ($newPage->content()->get("robots{$upper}")->value() === "") {
|
||||||
|
$newPage = $newPage->update([
|
||||||
|
"robots{$upper}" => 'default'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'page.render:before' => function (string $contentType, array $data, Page $page) {
|
||||||
|
if (option('tobimori.seo.generateSchema')) {
|
||||||
|
$page->schema('WebSite')
|
||||||
|
->url($page->metadata()->canonicalUrl())
|
||||||
|
->copyrightYear(date('Y'))
|
||||||
|
->description($page->metadata()->metaDescription())
|
||||||
|
->name($page->metadata()->metaTitle())
|
||||||
|
->headline($page->metadata()->title());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
82
site/plugins/kirby-seo/config/options.php
Normal file
82
site/plugins/kirby-seo/config/options.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cascade' => [
|
||||||
|
'fields',
|
||||||
|
'programmatic',
|
||||||
|
// 'fallbackFields', // fallback to meta fields for open graph fields
|
||||||
|
'parent',
|
||||||
|
'site',
|
||||||
|
'options'
|
||||||
|
],
|
||||||
|
'default' => [ // default field values for metadata, format is [field => value]
|
||||||
|
'metaTitle' => fn (Page $page) => $page->title(),
|
||||||
|
'metaTemplate' => '{{ title }} - {{ site.title }}',
|
||||||
|
'ogTemplate' => '{{ title }}',
|
||||||
|
'ogSiteName' => fn (Page $page) => $page->site()->title(),
|
||||||
|
'ogType' => 'website',
|
||||||
|
'twitterCardType' => 'summary',
|
||||||
|
'ogDescription' => fn (Page $page) => $page->metadata()->metaDescription(),
|
||||||
|
'twitterCreator' => fn (Page $page) => $page->metadata()->twitterSite(),
|
||||||
|
'lang' => fn (Page $page) => $page->kirby()->language()?->locale(LC_ALL) ?? $page->kirby()->option('tobimori.seo.lang', 'en_US'),
|
||||||
|
// default for robots: noIndex if global index configuration is set, otherwise fall back to page status
|
||||||
|
'robotsIndex' => function (Page $page) {
|
||||||
|
$index = $page->kirby()->option('tobimori.seo.robots.index');
|
||||||
|
if (is_callable($index)) {
|
||||||
|
$index = $index();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page->kirby()->option('tobimori.seo.robots.followPageStatus', true) ? $page->isListed() : true;
|
||||||
|
},
|
||||||
|
'robotsFollow' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||||
|
'robotsArchive' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||||
|
'robotsImageindex' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||||
|
'robotsSnippet' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
|
||||||
|
],
|
||||||
|
'socialMedia' => [ // default fields for social media links, format is [field => placeholder]
|
||||||
|
'twitter' => 'https://twitter.com/my-company',
|
||||||
|
'facebook' => 'https://facebook.com/my-company',
|
||||||
|
'instagram' => 'https://instagram.com/my-company',
|
||||||
|
'youtube' => 'https://youtube.com/channel/my-company',
|
||||||
|
'linkedin' => 'https://linkedin.com/company/my-company',
|
||||||
|
],
|
||||||
|
'previews' => [
|
||||||
|
'google',
|
||||||
|
'facebook',
|
||||||
|
'slack'
|
||||||
|
],
|
||||||
|
'robots' => [
|
||||||
|
'active' => true, // whether robots handling should be done by the plugin
|
||||||
|
'followPageStatus' => true, // should unlisted pages be noindex by default?
|
||||||
|
'pageSettings' => true, // whether to have robots settings on each page
|
||||||
|
'indicator' => true, // whether the indicator should be shown in the panel
|
||||||
|
'index' => fn () => !option('debug'), // default site-wide robots setting
|
||||||
|
'sitemap' => null, // sets sitemap url, will be replaced by plugin sitemap in the future
|
||||||
|
'content' => [], // custom robots content
|
||||||
|
'types' => ['index', 'follow', 'archive', 'imageindex', 'snippet'] // available robots types
|
||||||
|
],
|
||||||
|
'sitemap' => [
|
||||||
|
'active' => true,
|
||||||
|
'redirect' => true, // redirect /sitemap to /sitemap.xml
|
||||||
|
'lang' => 'en',
|
||||||
|
'generator' => require __DIR__ . '/options/sitemap.php',
|
||||||
|
'changefreq' => 'weekly',
|
||||||
|
'groupByTemplate' => false,
|
||||||
|
'excludeTemplates' => ['error'],
|
||||||
|
'priority' => fn (Page $p) => number_format(($p->isHomePage()) ? 1 : max(1 - 0.2 * $p->depth(), 0.2), 1),
|
||||||
|
],
|
||||||
|
'files' => [
|
||||||
|
'parent' => null,
|
||||||
|
'template' => null,
|
||||||
|
],
|
||||||
|
'canonicalBase' => null, // base url for canonical links
|
||||||
|
'generateSchema' => true, // whether to generate default schema.org data
|
||||||
|
'lang' => 'en_US', // default language, used for single-language sites
|
||||||
|
'dateFormat' => null, // custom date format
|
||||||
|
];
|
||||||
37
site/plugins/kirby-seo/config/options/sitemap.php
Normal file
37
site/plugins/kirby-seo/config/options/sitemap.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Toolkit\Obj;
|
||||||
|
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||||
|
|
||||||
|
return function (SitemapIndex $sitemap) {
|
||||||
|
$exclude = option('tobimori.seo.sitemap.excludeTemplates', []);
|
||||||
|
$pages = site()->index()->filter(fn ($page) => $page->metadata()->robotsIndex()->toBool() && !in_array($page->intendedTemplate()->name(), $exclude));
|
||||||
|
|
||||||
|
if ($group = option('tobimori.seo.sitemap.groupByTemplate')) {
|
||||||
|
$pages = $pages->group('intendedTemplate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_a($pages->first(), 'Kirby\Cms\Page')) {
|
||||||
|
$pages = $pages->group(fn () => 'pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pages as $group) {
|
||||||
|
$index = $sitemap->create($group ? $group->first()->intendedTemplate()->name() : 'pages');
|
||||||
|
|
||||||
|
foreach ($group as $page) {
|
||||||
|
$url = $index->createUrl($page->metadata()->canonicalUrl())
|
||||||
|
->lastmod($page->modified() ?? (int)(date('c')))
|
||||||
|
->changefreq(is_callable($changefreq = option('tobimori.seo.sitemap.changefreq')) ? $changefreq($page) : $changefreq)
|
||||||
|
->priority(is_callable($priority = option('tobimori.seo.sitemap.priority')) ? $priority($page) : $priority);
|
||||||
|
|
||||||
|
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
|
||||||
|
$url->alternates(
|
||||||
|
kirby()->languages()->map(fn ($language) => new Obj([
|
||||||
|
'hreflang' => $language->code() === kirby()->language()->code() ? 'x-default' : $language->code(),
|
||||||
|
'href' => $page->url($language->code()),
|
||||||
|
]))->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
11
site/plugins/kirby-seo/config/pageMethods.php
Normal file
11
site/plugins/kirby-seo/config/pageMethods.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use tobimori\Seo\Meta;
|
||||||
|
use tobimori\Seo\SchemaSingleton;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema' => fn ($type) => SchemaSingleton::getInstance($type, $this),
|
||||||
|
'schemas' => fn () => SchemaSingleton::getInstances($this),
|
||||||
|
'metadata' => fn (?string $lang = null) => new Meta($this, $lang),
|
||||||
|
'robots' => fn (?string $lang = null) => $this->metadata($lang)->robots(),
|
||||||
|
];
|
||||||
98
site/plugins/kirby-seo/config/routes.php
Normal file
98
site/plugins/kirby-seo/config/routes.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
use Kirby\Http\Response;
|
||||||
|
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'pattern' => 'robots.txt',
|
||||||
|
'action' => function () {
|
||||||
|
if (option('tobimori.seo.robots.active', true)) {
|
||||||
|
$content = snippet('seo/robots.txt', [], true);
|
||||||
|
return new Response($content, 'text/plain', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => 'sitemap',
|
||||||
|
'action' => function () {
|
||||||
|
if (!option('tobimori.seo.sitemap.redirect', true) || !option('tobimori.seo.sitemap.active', true)) {
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
go('/sitemap.xml');
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => 'sitemap.xsl',
|
||||||
|
'action' => function () {
|
||||||
|
if (!option('tobimori.seo.sitemap.active', true)) {
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
kirby()->response()->type('text/xsl');
|
||||||
|
|
||||||
|
$lang = option('tobimori.seo.sitemap.lang', 'en');
|
||||||
|
if (is_callable($lang)) {
|
||||||
|
$lang = $lang();
|
||||||
|
}
|
||||||
|
kirby()->setCurrentTranslation($lang);
|
||||||
|
|
||||||
|
return Page::factory([
|
||||||
|
'slug' => 'sitemap',
|
||||||
|
'template' => 'sitemap',
|
||||||
|
'model' => 'sitemap',
|
||||||
|
'content' => [
|
||||||
|
'title' => t('sitemap'),
|
||||||
|
],
|
||||||
|
])->render(contentType: 'xsl');
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => 'sitemap.xml',
|
||||||
|
'action' => function () {
|
||||||
|
if (!option('tobimori.seo.sitemap.active', true)) {
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
SitemapIndex::instance()->generate();
|
||||||
|
kirby()->response()->type('text/xml');
|
||||||
|
return Page::factory([
|
||||||
|
'slug' => 'sitemap',
|
||||||
|
'template' => 'sitemap',
|
||||||
|
'model' => 'sitemap',
|
||||||
|
'content' => [
|
||||||
|
'title' => t('sitemap'),
|
||||||
|
'index' => null,
|
||||||
|
],
|
||||||
|
])->render(contentType: 'xml');
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pattern' => 'sitemap-(:any).xml',
|
||||||
|
'action' => function (string $index) {
|
||||||
|
if (!option('tobimori.seo.sitemap.active', true)) {
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
SitemapIndex::instance()->generate();
|
||||||
|
if (!SitemapIndex::instance()->isValidIndex($index)) {
|
||||||
|
$this->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
kirby()->response()->type('text/xml');
|
||||||
|
return Page::factory([
|
||||||
|
'slug' => 'sitemap-' . $index,
|
||||||
|
'template' => 'sitemap',
|
||||||
|
'model' => 'sitemap',
|
||||||
|
'content' => [
|
||||||
|
'title' => t('sitemap'),
|
||||||
|
'index' => $index,
|
||||||
|
],
|
||||||
|
])->render(contentType: 'xml');
|
||||||
|
}
|
||||||
|
]
|
||||||
|
];
|
||||||
20
site/plugins/kirby-seo/config/sections.php
Normal file
20
site/plugins/kirby-seo/config/sections.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Toolkit\A;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'seo-preview' => [
|
||||||
|
'mixins' => ['headline'],
|
||||||
|
'computed' => [
|
||||||
|
'options' => function () {
|
||||||
|
return A::map(option('tobimori.seo.previews'), fn ($item) => [
|
||||||
|
'value' => $item,
|
||||||
|
'text' => t($item)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'heading-structure' => [
|
||||||
|
'mixins' => ['headline']
|
||||||
|
]
|
||||||
|
];
|
||||||
28
site/plugins/kirby-seo/config/siteMethods.php
Normal file
28
site/plugins/kirby-seo/config/siteMethods.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Http\Url;
|
||||||
|
use Kirby\Toolkit\Str;
|
||||||
|
use tobimori\Seo\SchemaSingleton;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema' => fn ($type) => SchemaSingleton::getInstance($type),
|
||||||
|
'schemas' => fn () => SchemaSingleton::getInstances(),
|
||||||
|
'lang' => fn () => Str::replace(option('tobimori.seo.default.lang')($this->homePage()), '_', '-'),
|
||||||
|
'canonicalFor' => function (string $url) {
|
||||||
|
$base = option('tobimori.seo.canonicalBase');
|
||||||
|
if (is_callable($base)) {
|
||||||
|
$base = $base($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($base === null) {
|
||||||
|
$base = $this->url(); // graceful fallback to site url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Str::startsWith($url, $base)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = Url::path($url);
|
||||||
|
return url($base . '/' . $path);
|
||||||
|
}
|
||||||
|
];
|
||||||
1
site/plugins/kirby-seo/index.css
Normal file
1
site/plugins/kirby-seo/index.css
Normal file
File diff suppressed because one or more lines are too long
1
site/plugins/kirby-seo/index.js
Normal file
1
site/plugins/kirby-seo/index.js
Normal file
File diff suppressed because one or more lines are too long
69
site/plugins/kirby-seo/index.php
Normal file
69
site/plugins/kirby-seo/index.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
@include_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Kirby\Cms\App;
|
||||||
|
use Kirby\Data\Yaml;
|
||||||
|
use Kirby\Filesystem\Dir;
|
||||||
|
use Kirby\Filesystem\F;
|
||||||
|
use Kirby\Toolkit\A;
|
||||||
|
use Spatie\SchemaOrg\Schema;
|
||||||
|
|
||||||
|
// shamelessly borrowed from distantnative/retour-for-kirby
|
||||||
|
if (
|
||||||
|
version_compare(App::version() ?? '0.0.0', '4.0.2', '<') === true ||
|
||||||
|
version_compare(App::version() ?? '0.0.0', '5.0.0', '>') === true
|
||||||
|
) {
|
||||||
|
throw new Exception('Kirby SEO requires Kirby 4.0.2 or higher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
App::plugin('tobimori/seo', [
|
||||||
|
'options' => require __DIR__ . '/config/options.php',
|
||||||
|
'sections' => require __DIR__ . '/config/sections.php',
|
||||||
|
'api' => require __DIR__ . '/config/api.php',
|
||||||
|
'siteMethods' => require __DIR__ . '/config/siteMethods.php',
|
||||||
|
'pageMethods' => require __DIR__ . '/config/pageMethods.php',
|
||||||
|
'hooks' => require __DIR__ . '/config/hooks.php',
|
||||||
|
'routes' => require __DIR__ . '/config/routes.php',
|
||||||
|
// load all commands automatically
|
||||||
|
'commands' => A::keyBy(A::map(
|
||||||
|
Dir::read(__DIR__ . '/config/commands'),
|
||||||
|
fn ($file) => A::merge([
|
||||||
|
'id' => 'seo:' . F::name($file),
|
||||||
|
], require __DIR__ . '/config/commands/' . $file)
|
||||||
|
), 'id'),
|
||||||
|
// get all files from /translations and register them as language files
|
||||||
|
'translations' => A::keyBy(A::map(
|
||||||
|
Dir::read(__DIR__ . '/translations'),
|
||||||
|
fn ($file) => A::merge([
|
||||||
|
'lang' => F::name($file),
|
||||||
|
], Yaml::decode(F::read(__DIR__ . '/translations/' . $file)))
|
||||||
|
), 'lang'),
|
||||||
|
'snippets' => [
|
||||||
|
'seo/schemas' => __DIR__ . '/snippets/schemas.php',
|
||||||
|
'seo/head' => __DIR__ . '/snippets/head.php',
|
||||||
|
'seo/robots.txt' => __DIR__ . '/snippets/robots.txt.php',
|
||||||
|
],
|
||||||
|
'templates' => [
|
||||||
|
'sitemap' => __DIR__ . '/templates/sitemap.php',
|
||||||
|
'sitemap.xml' => __DIR__ . '/templates/sitemap.xml.php',
|
||||||
|
'sitemap.xsl' => __DIR__ . '/templates/sitemap.xsl.php',
|
||||||
|
],
|
||||||
|
'blueprints' => [
|
||||||
|
'seo/site' => __DIR__ . '/blueprints/site.yml',
|
||||||
|
'seo/page' => __DIR__ . '/blueprints/page.yml',
|
||||||
|
'seo/fields/og-image' => require __DIR__ . '/blueprints/fields/og-image.php',
|
||||||
|
'seo/fields/og-group' => __DIR__ . '/blueprints/fields/og-group.yml',
|
||||||
|
'seo/fields/meta-group' => __DIR__ . '/blueprints/fields/meta-group.yml',
|
||||||
|
'seo/fields/robots' => require __DIR__ . '/blueprints/fields/robots.php',
|
||||||
|
'seo/fields/site-robots' => require __DIR__ . '/blueprints/fields/site-robots.php',
|
||||||
|
'seo/fields/social-media' => require __DIR__ . '/blueprints/fields/social-media.php',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!function_exists('schema')) {
|
||||||
|
function schema($type)
|
||||||
|
{
|
||||||
|
return Schema::{$type}();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
site/plugins/kirby-seo/package.json
Normal file
17
site/plugins/kirby-seo/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Tobias Möritz",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "kirbyup serve src/index.js",
|
||||||
|
"build": "kirbyup src/index.js",
|
||||||
|
"prepare": "husky install"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.0.11",
|
||||||
|
"kirbyup": "^3.1.4",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"sass": "^1.71.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
site/plugins/kirby-seo/postcss.config.js
Normal file
6
site/plugins/kirby-seo/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'postcss-logical': {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
site/plugins/kirby-seo/snippets/head.php
Normal file
13
site/plugins/kirby-seo/snippets/head.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Kirby\Cms\Page $page
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Kirby\Cms\Html;
|
||||||
|
|
||||||
|
$tags = $page->metadata()->snippetData();
|
||||||
|
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
echo Html::tag($tag['tag'], $tag['content'] ?? null, $tag['attributes'] ?? []) . PHP_EOL;
|
||||||
|
}
|
||||||
58
site/plugins/kirby-seo/snippets/robots.txt.php
Normal file
58
site/plugins/kirby-seo/snippets/robots.txt.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Kirby\Toolkit\A;
|
||||||
|
|
||||||
|
if ($content = option('tobimori.seo.robots.content')) {
|
||||||
|
if (is_callable($content)) {
|
||||||
|
$content = $content();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($content)) {
|
||||||
|
$str = [];
|
||||||
|
|
||||||
|
foreach ($content as $ua => $data) {
|
||||||
|
$str[] = 'User-agent: ' . $ua;
|
||||||
|
foreach ($data as $type => $values) {
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$str[] = $type . ': ' . $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = A::join($str, PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $content;
|
||||||
|
} else {
|
||||||
|
// output default
|
||||||
|
echo "User-agent: *\n";
|
||||||
|
|
||||||
|
$index = option('tobimori.seo.robots.index');
|
||||||
|
if (is_callable($index)) {
|
||||||
|
$index = $index();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($index) {
|
||||||
|
echo 'Allow: /';
|
||||||
|
echo "\nDisallow: /panel";
|
||||||
|
} else {
|
||||||
|
echo 'Disallow: /';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($sitemap = option('tobimori.seo.robots.sitemap')) || ($sitemapModule = option('tobimori.seo.sitemap.active'))) {
|
||||||
|
// Allow closure to be used
|
||||||
|
if (is_callable($sitemap)) {
|
||||||
|
$sitemap = $sitemap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default sitemap if none is set
|
||||||
|
if (!$sitemap && $sitemapModule) {
|
||||||
|
$sitemap = site()->canonicalFor('/sitemap.xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check again, so falsy values can't be used
|
||||||
|
if ($sitemap) {
|
||||||
|
echo "\n\nSitemap: {$sitemap}";
|
||||||
|
}
|
||||||
|
}
|
||||||
8
site/plugins/kirby-seo/snippets/schemas.php
Normal file
8
site/plugins/kirby-seo/snippets/schemas.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$siteSchema ??= true;
|
||||||
|
$pageSchema ??= true;
|
||||||
|
|
||||||
|
foreach (array_merge($siteSchema ? $site->schemas() : [], $pageSchema ? $page->schemas() : []) as $schema) {
|
||||||
|
echo $schema;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="k-facebook-preview">
|
||||||
|
<div class="k-facebook-preview__image" v-if="ogImage">
|
||||||
|
<img :src="ogImage" class="k-facebook-preview__img" />
|
||||||
|
</div>
|
||||||
|
<div class="k-facebook-preview__content">
|
||||||
|
<span class="k-facebook-preview__url">{{ host }}</span>
|
||||||
|
<span class="k-facebook-preview__title">{{ ogTitle }}</span>
|
||||||
|
<p class="k-facebook-preview__description">{{ ogDescription }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="k-seo-preview__debugger"
|
||||||
|
href="https://developers.facebook.com/tools/debug/"
|
||||||
|
aria-label="Facebook Sharing Debugger"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ $t('open-debugger') }}
|
||||||
|
<k-icon type="open" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ogTitle: String,
|
||||||
|
url: String,
|
||||||
|
ogDescription: String,
|
||||||
|
ogImage: String
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
host() {
|
||||||
|
return new URL(this.url).host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-facebook-preview {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: 1px solid #ced0d4;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 52.355%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title,
|
||||||
|
&__description,
|
||||||
|
&__url {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__url {
|
||||||
|
color: #65676b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1765;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #050505;
|
||||||
|
margin: 0.3125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
line-height: 1.3333;
|
||||||
|
color: #65676b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
site/plugins/kirby-seo/src/components/Previews/GooglePreview.vue
Normal file
115
site/plugins/kirby-seo/src/components/Previews/GooglePreview.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="k-google-search-preview">
|
||||||
|
<span class="k-google-search-preview__url">
|
||||||
|
<span>{{ origin }}</span>
|
||||||
|
<span
|
||||||
|
v-for="(breadcrumb, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
class="k-google-search-preview__url__breadcrumb"
|
||||||
|
>
|
||||||
|
{{ breadcrumb }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<h2 class="k-google-search-preview__headline">{{ title }}</h2>
|
||||||
|
<p class="k-google-search-preview__paragraph">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="k-seo-preview__debugger"
|
||||||
|
href="https://search.google.com/search-console"
|
||||||
|
aria-label="Google Search Console"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ $t('open-search-console') }}
|
||||||
|
<k-icon type="open" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
description: String
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
origin() {
|
||||||
|
return new URL(this.url).origin
|
||||||
|
},
|
||||||
|
breadcrumbs() {
|
||||||
|
return this.url.split('/').slice(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-google-search-preview {
|
||||||
|
padding: 1em;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
|
||||||
|
&__headline,
|
||||||
|
&__paragraph {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__headline {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #1a0dab;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__url {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #202124;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__breadcrumb {
|
||||||
|
color: #5f6368;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '› ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-icon {
|
||||||
|
margin-left: 0.1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__paragraph {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.3em;
|
||||||
|
color: #3c4043;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<div class="k-slack-preview">
|
||||||
|
<div class="k-slack-preview__content">
|
||||||
|
<div class="k-slack-preview__site-name">{{ ogSiteName || origin }}</div>
|
||||||
|
<span class="k-slack-preview__title">{{ ogTitle }}</span>
|
||||||
|
<p class="k-slack-preview__description">{{ ogDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="k-slack-preview__image" v-if="ogImage">
|
||||||
|
<img :src="ogImage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
ogTitle: String,
|
||||||
|
ogSiteName: String,
|
||||||
|
ogDescription: String,
|
||||||
|
ogImage: String
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
origin() {
|
||||||
|
return new URL(this.url).origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-slack-preview {
|
||||||
|
max-width: 32.5rem;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1rem;
|
||||||
|
line-height: 1.46666667;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__site-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #717274;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 700;
|
||||||
|
display: block;
|
||||||
|
color: #0576b9;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
color: #2c2d30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 22.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
content: '';
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
site/plugins/kirby-seo/src/components/Views/PageView.vue
Normal file
150
site/plugins/kirby-seo/src/components/Views/PageView.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
https://github.com/getkirby/kirby/blob/main/panel/src/components/Views/Pages/PageView.vue
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<k-panel-inside
|
||||||
|
:data-has-tabs="tabs.length > 1"
|
||||||
|
:data-id="model.id"
|
||||||
|
:data-locked="isLocked"
|
||||||
|
:data-template="blueprint"
|
||||||
|
class="k-page-view"
|
||||||
|
>
|
||||||
|
<template #topbar>
|
||||||
|
<k-prev-next v-if="model.id" :prev="prev" :next="next" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<k-header
|
||||||
|
:editable="permissions.changeTitle && !isLocked"
|
||||||
|
class="k-page-view-header"
|
||||||
|
@edit="$dialog(id + '/changeTitle')"
|
||||||
|
>
|
||||||
|
{{ model.title }}
|
||||||
|
<template #buttons>
|
||||||
|
<k-button-group>
|
||||||
|
<k-button
|
||||||
|
v-if="permissions.preview && model.previewUrl"
|
||||||
|
:link="model.previewUrl"
|
||||||
|
:title="$t('open')"
|
||||||
|
icon="open"
|
||||||
|
target="_blank"
|
||||||
|
variant="filled"
|
||||||
|
size="sm"
|
||||||
|
class="k-page-view-preview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<k-button
|
||||||
|
:disabled="isLocked === true"
|
||||||
|
:dropdown="true"
|
||||||
|
:title="$t('settings')"
|
||||||
|
icon="cog"
|
||||||
|
variant="filled"
|
||||||
|
size="sm"
|
||||||
|
class="k-page-view-options"
|
||||||
|
@click="$refs.settings.toggle()"
|
||||||
|
/>
|
||||||
|
<k-dropdown-content ref="settings" :options="$dropdown(id)" align-x="end" />
|
||||||
|
|
||||||
|
<k-languages-dropdown />
|
||||||
|
|
||||||
|
<k-button
|
||||||
|
v-if="status"
|
||||||
|
v-bind="statusBtn"
|
||||||
|
class="k-page-view-status"
|
||||||
|
variant="filled"
|
||||||
|
@click="$dialog(id + '/changeStatus')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<k-button
|
||||||
|
class="k-page-view-status k-page-view-robots"
|
||||||
|
v-if="robots && robots.active"
|
||||||
|
v-bind="robotsBtn"
|
||||||
|
@click="openSeoTab"
|
||||||
|
/>
|
||||||
|
</k-button-group>
|
||||||
|
|
||||||
|
<k-form-buttons :lock="lock" />
|
||||||
|
</template>
|
||||||
|
</k-header>
|
||||||
|
|
||||||
|
<k-model-tabs :tab="tab.name" :tabs="tabs" />
|
||||||
|
|
||||||
|
<k-sections
|
||||||
|
:blueprint="blueprint"
|
||||||
|
:empty="$t('page.blueprint', { blueprint: $esc(blueprint) })"
|
||||||
|
:lock="lock"
|
||||||
|
:parent="id"
|
||||||
|
:tab="tab"
|
||||||
|
/>
|
||||||
|
</k-panel-inside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
extends: 'k-page-view',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dirty: false,
|
||||||
|
robots: {
|
||||||
|
active: false,
|
||||||
|
state: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.handleLoad()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openSeoTab() {
|
||||||
|
panel.view.open(panel.view.path + '?tab=seo')
|
||||||
|
},
|
||||||
|
async handleLoad(changes) {
|
||||||
|
if (!panel.view.props.tabs.some((tab) => tab.name === 'seo')) return
|
||||||
|
|
||||||
|
const page = this.model.id.replaceAll('/', '+')
|
||||||
|
this.robots = await panel.api.post(`/k-seo/${page}/robots`, changes ?? this.changes)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
changes() {
|
||||||
|
return this.$store.getters['content/changes']() // TODO: new panel API for changes?
|
||||||
|
},
|
||||||
|
robotsBtn() {
|
||||||
|
const btn = {
|
||||||
|
responsive: true,
|
||||||
|
size: 'sm',
|
||||||
|
icon: 'robots',
|
||||||
|
theme: 'positive',
|
||||||
|
text: this.$t('indicator-index'),
|
||||||
|
variant: 'filled'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.robots.state.includes('no')) {
|
||||||
|
btn.text = this.$t('indicator-any')
|
||||||
|
btn.theme = 'notice'
|
||||||
|
btn.icon = 'robots-off'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.robots.state.includes('noindex')) {
|
||||||
|
btn.text = this.$t('indicator-noindex')
|
||||||
|
btn.theme = 'negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
changes(changes) {
|
||||||
|
if (Object.keys(changes).some((key) => key.includes('robots')) || this.dirty) {
|
||||||
|
this.dirty = false
|
||||||
|
this.handleLoad(changes)
|
||||||
|
if (changes) this.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-page-view-robots {
|
||||||
|
--color-green-boost: -15%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
site/plugins/kirby-seo/src/index.js
Normal file
13
site/plugins/kirby-seo/src/index.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { kirbyup } from 'kirbyup/plugin'
|
||||||
|
import PageView from './components/Views/PageView.vue'
|
||||||
|
|
||||||
|
panel.plugin('tobimori/seo', {
|
||||||
|
icons: {
|
||||||
|
robots: `<path d="M13.5 2c0 .444-.193.843-.5 1.118V5h5a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h5V3.118A1.5 1.5 0 1 1 13.5 2ZM6 7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H6Zm-4 3H0v6h2v-6Zm20 0h2v6h-2v-6ZM9 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm6 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />`,
|
||||||
|
'robots-off': `<path fill-rule="evenodd" clip-rule="evenodd" d="M21 16.786V8a3 3 0 0 0-3-3h-5V3.118a1.5 1.5 0 1 0-2 0V5H9.214l2 2H18a1 1 0 0 1 1 1v6.786l2 2ZM2.093 3.507l2.099 2.099A2.995 2.995 0 0 0 3 8v10a3 3 0 0 0 3 3h12c.463 0 .902-.105 1.293-.292l1.9 1.9 1.414-1.415-6.88-6.88a1.5 1.5 0 1 0-2.04-2.04L3.508 2.093 2.093 3.507ZM5 8a1 1 0 0 1 .65-.937L17.585 19H6a1 1 0 0 1-1-1V8Zm-5 2h2v6H0v-6Zm24 0h-2v6h2v-6Zm-13.5 3a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />`
|
||||||
|
},
|
||||||
|
sections: kirbyup.import('./sections/*.vue'),
|
||||||
|
components: {
|
||||||
|
'k-page-view': PageView
|
||||||
|
}
|
||||||
|
})
|
||||||
191
site/plugins/kirby-seo/src/sections/heading-structure.vue
Normal file
191
site/plugins/kirby-seo/src/sections/heading-structure.vue
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<template>
|
||||||
|
<div class="k-section k-heading-structure" v-if="value">
|
||||||
|
<div class="k-field-header k-heading-structure__label k-label k-field-label">
|
||||||
|
<k-icon type="headline" />
|
||||||
|
<span class="k-label-text">{{ label || $t('heading-structure') }}</span>
|
||||||
|
<k-loader v-if="isLoading" />
|
||||||
|
</div>
|
||||||
|
<k-box theme="white">
|
||||||
|
<ol class="k-heading-structure__list">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in value"
|
||||||
|
:key="index"
|
||||||
|
:style="`z-index: ${value.length - index}`"
|
||||||
|
:class="`k-heading-structure__item level-${item.level} ${
|
||||||
|
itemInvalid(item, index) ? 'is-invalid' : ''
|
||||||
|
}`"
|
||||||
|
>
|
||||||
|
<span class="k-heading-structure__item__level">H{{ item.level }}</span>
|
||||||
|
<span class="k-heading-structure__item__text">{{ item.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</k-box>
|
||||||
|
<k-box class="k-heading-structure__notice" theme="negative" v-if="incorrectOrder && !noH1">
|
||||||
|
<k-icon type="alert" />
|
||||||
|
<k-text>{{ $t('incorrect-heading-order') }}</k-text>
|
||||||
|
</k-box>
|
||||||
|
<k-box class="k-heading-structure__notice" theme="negative" v-if="multipleH1">
|
||||||
|
<k-icon type="alert" />
|
||||||
|
<k-text>{{ $t('multiple-h1-tags') }}</k-text>
|
||||||
|
</k-box>
|
||||||
|
<k-box class="k-heading-structure__notice" theme="negative" v-if="noH1">
|
||||||
|
<k-icon type="alert" />
|
||||||
|
<k-text>{{ $t('missing-h1-tag') }}</k-text>
|
||||||
|
</k-box>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
label: null,
|
||||||
|
value: null,
|
||||||
|
isLoading: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.isLoading = true
|
||||||
|
|
||||||
|
this.load().then((data) => {
|
||||||
|
this.label = data.label
|
||||||
|
}) // loads label and properties
|
||||||
|
this.handleLoad() // handles metadata & title change
|
||||||
|
|
||||||
|
this.debouncedLoad = this.$helper.debounce((changes) => {
|
||||||
|
this.handleLoad(changes)
|
||||||
|
}, 200) // debounce function for dirty changes watcher
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
changes() {
|
||||||
|
return this.$store.getters['content/changes']()
|
||||||
|
},
|
||||||
|
incorrectOrder() {
|
||||||
|
return this.value?.some((item, index) => item.level > (this.value[index - 1]?.level ?? 0) + 1)
|
||||||
|
},
|
||||||
|
multipleH1() {
|
||||||
|
return this.value?.filter((item) => item.level === 1).length > 1
|
||||||
|
},
|
||||||
|
noH1() {
|
||||||
|
return this.value?.filter((item) => item.level === 1).length === 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleLoad(changes) {
|
||||||
|
this.isLoading = true
|
||||||
|
|
||||||
|
const page = panel.view.props.model.id
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('[kirby-seo] The Heading structure section is only available for pages')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await panel.api.post(
|
||||||
|
`/k-seo/${page.replaceAll('/', '+')}/heading-structure`,
|
||||||
|
changes ?? this.changes
|
||||||
|
)
|
||||||
|
|
||||||
|
this.value = response
|
||||||
|
this.isLoading = false
|
||||||
|
},
|
||||||
|
itemInvalid(item, index) {
|
||||||
|
if (item.level > (this.value[index - 1]?.level ?? 0) + 1) return true // wrong order
|
||||||
|
if (item.level === 1 && this.value[index - 1]) return true // wrong order
|
||||||
|
if (item.level === 1 && this.value.filter((item) => item.level === 1).length > 1) return true // multiple h1
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
changes(changes) {
|
||||||
|
this.debouncedLoad(changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-heading-structure {
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
|
||||||
|
> .k-icon {
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .k-loader {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notice {
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
> .k-icon {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
margin-right: var(--spacing-1);
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--theme-color-back);
|
||||||
|
padding-block: var(--spacing-px);
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&__level {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-invalid {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 2 through 6 {
|
||||||
|
&.level-#{$i} {
|
||||||
|
margin-left: ($i - 2) * 1.6rem;
|
||||||
|
padding-left: 1.6rem;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 0.0625rem);
|
||||||
|
left: 0.4rem;
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.125rem;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(50% - 0.0625rem);
|
||||||
|
left: 0.4rem;
|
||||||
|
height: 9999px;
|
||||||
|
width: 0.125rem;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
site/plugins/kirby-seo/src/sections/seo-preview.vue
Normal file
126
site/plugins/kirby-seo/src/sections/seo-preview.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<template>
|
||||||
|
<div class="k-section k-seo-preview">
|
||||||
|
<div class="k-field-header k-seo-preview__label k-label k-field-label">
|
||||||
|
<k-icon type="preview" /><span class="k-label-text">{{ label || $t('seo-preview') }}</span>
|
||||||
|
<k-loader v-if="isLoading" />
|
||||||
|
</div>
|
||||||
|
<k-select-field
|
||||||
|
type="select"
|
||||||
|
name="seo-preview-type"
|
||||||
|
:before="$t('seo-preview-for')"
|
||||||
|
v-model="type"
|
||||||
|
:options="options"
|
||||||
|
:empty="false"
|
||||||
|
/>
|
||||||
|
<div class="k-seo-preview__inner" v-if="value">
|
||||||
|
<google-preview v-if="type === 'google'" v-bind="value" />
|
||||||
|
<facebook-preview v-if="type === 'facebook'" v-bind="value" />
|
||||||
|
<slack-preview v-if="type === 'slack'" v-bind="value" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FacebookPreview from '../components/Previews/FacebookPreview.vue'
|
||||||
|
import GooglePreview from '../components/Previews/GooglePreview.vue'
|
||||||
|
import SlackPreview from '../components/Previews/SlackPreview.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { GooglePreview, FacebookPreview, SlackPreview },
|
||||||
|
data() {
|
||||||
|
const type = localStorage.getItem('kSEOPreviewType') ?? 'google'
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: null,
|
||||||
|
value: null,
|
||||||
|
isLoading: true,
|
||||||
|
options: [],
|
||||||
|
type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.isLoading = true
|
||||||
|
|
||||||
|
this.load().then((data) => {
|
||||||
|
this.label = data.label
|
||||||
|
this.options = data.options
|
||||||
|
}) // loads label and properties
|
||||||
|
this.handleLoad() // handles metadata & title change
|
||||||
|
|
||||||
|
this.debouncedLoad = this.$helper.debounce((changes) => {
|
||||||
|
this.handleLoad(changes)
|
||||||
|
}, 200) // debounce function for dirty changes watcher
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
changes() {
|
||||||
|
return this.$store.getters['content/changes']()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleLoad(changes) {
|
||||||
|
this.isLoading = true
|
||||||
|
|
||||||
|
const page = panel.view.props.model?.id?.replaceAll('/', '+') ?? 'site'
|
||||||
|
const response = await panel.api.post(`/k-seo/${page}/seo-preview`, changes ?? this.changes)
|
||||||
|
|
||||||
|
this.value = response
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
changes(changes) {
|
||||||
|
this.debouncedLoad(changes)
|
||||||
|
},
|
||||||
|
type() {
|
||||||
|
localStorage.setItem('kSEOPreviewType', this.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.k-field-name-seo-preview-type .k-field-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-seo-preview {
|
||||||
|
&__inner {
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__debugger {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
line-height: 1.25rem;
|
||||||
|
width: max-content;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--text-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .k-icon {
|
||||||
|
margin-left: var(--spacing-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
|
||||||
|
> .k-icon {
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .k-loader {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
site/plugins/kirby-seo/templates/sitemap.php
Normal file
3
site/plugins/kirby-seo/templates/sitemap.php
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
go($page->url() . '.xml');
|
||||||
5
site/plugins/kirby-seo/templates/sitemap.xml.php
Normal file
5
site/plugins/kirby-seo/templates/sitemap.xml.php
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use tobimori\Seo\Sitemap\SitemapIndex;
|
||||||
|
|
||||||
|
echo SitemapIndex::instance()->render($page);
|
||||||
196
site/plugins/kirby-seo/templates/sitemap.xsl.php
Normal file
196
site/plugins/kirby-seo/templates/sitemap.xsl.php
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
<?= '<?xml version="1.0" encoding="UTF-8"?>' ?>
|
||||||
|
<xsl:stylesheet version="2.0" xmlns:html="http://www.w3.org/TR/REC-html40" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||||
|
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
|
||||||
|
<xsl:template match="/">
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title><?= $page->metadata()->title()->escape() ?></title>
|
||||||
|
<style>
|
||||||
|
/* Document styles */
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #000;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-sitemap-body {
|
||||||
|
max-width: 64rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font styles */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-sitemap-body>p {
|
||||||
|
color: rgb(115, 115, 115);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(29, 84, 139);
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-sitemap-credits {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px 0 #0000000d, 0 1px 2px 0 #00000006;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-sitemap-table-empty {
|
||||||
|
box-shadow: none;
|
||||||
|
border: rgb(204, 204, 204) 1px dashed;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: rgb(104, 104, 104);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-family: "SFMono-Regular", Consolas, Liberation Mono, Menlo, Courier, monospace;
|
||||||
|
color: rgb(115 115 115);
|
||||||
|
background: rgb(250 250 250);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:not(:last-child),
|
||||||
|
td:not(:last-child) {
|
||||||
|
border-inline-end: 1px solid rgb(240 240 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
tr:not(:last-child) td {
|
||||||
|
border-block-end: 1px solid rgb(240 240 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 48rem) {
|
||||||
|
.k-sitemap-secondary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="k-sitemap-body">
|
||||||
|
<h1><?= $page->title() ?></h1>
|
||||||
|
<p><?= t('sitemap-description') ?></p>
|
||||||
|
|
||||||
|
<xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) > 0">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="66%"><?= t('sitemap') ?></th>
|
||||||
|
<th width="33%" class="k-sitemap-secondary"><?= t('sitemap-last-updated') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<xsl:variable name="link">
|
||||||
|
<xsl:value-of select="sitemap:loc" />
|
||||||
|
</xsl:variable>
|
||||||
|
<a href="{$link}"><xsl:value-of select="sitemap:loc" /></a>
|
||||||
|
</td>
|
||||||
|
<td class="k-sitemap-secondary">
|
||||||
|
<xsl:value-of select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</xsl:for-each>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</xsl:if>
|
||||||
|
|
||||||
|
<xsl:if test="count(sitemap:urlset/sitemap:url) > 0">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="55%"><?= t('sitemap-url') ?></th>
|
||||||
|
<th width="10%" class="k-sitemap-secondary"><?= t('sitemap-priority') ?></th>
|
||||||
|
<th width="15%" class="k-sitemap-secondary"><?= t('sitemap-changefreq') ?></th>
|
||||||
|
<th width="20%" class="k-sitemap-secondary"><?= t('sitemap-last-updated') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<xsl:for-each select="sitemap:urlset/sitemap:url">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<xsl:variable name="link">
|
||||||
|
<xsl:value-of select="sitemap:loc" />
|
||||||
|
</xsl:variable>
|
||||||
|
<a target="_blank" rel="noopener nofollow" href="{$link}"><xsl:value-of select="sitemap:loc" /></a>
|
||||||
|
</td>
|
||||||
|
<td class="k-sitemap-secondary">
|
||||||
|
<xsl:value-of select="sitemap:priority" />
|
||||||
|
</td>
|
||||||
|
<td class="k-sitemap-secondary">
|
||||||
|
<xsl:value-of select="sitemap:changefreq" />
|
||||||
|
</td>
|
||||||
|
<td class="k-sitemap-secondary">
|
||||||
|
<xsl:value-of select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</xsl:for-each>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</xsl:if>
|
||||||
|
|
||||||
|
<xsl:if test="(sitemap:urlset and count(sitemap:urlset/sitemap:url) = 0) or (sitemap:sitemapindex and count(sitemap:sitemapindex/sitemap:sitemap) = 0)">
|
||||||
|
<div class="k-sitemap-table-empty">
|
||||||
|
<?= t('sitemap-no-entries') ?>
|
||||||
|
</div>
|
||||||
|
</xsl:if>
|
||||||
|
|
||||||
|
<p class="k-sitemap-credits">
|
||||||
|
<a target="_blank" rel="noopener nofollow" href="https://getkirby.com/plugins/tobimori/seo">Kirby SEO</a>
|
||||||
|
<xsl:if test="sitemap:urlset">
|
||||||
|
v<xsl:value-of select="sitemap:urlset/@seo-version" />
|
||||||
|
</xsl:if>
|
||||||
|
<xsl:if test="sitemap:sitemapindex">
|
||||||
|
v<xsl:value-of select="sitemap:sitemapindex/@seo-version" />
|
||||||
|
</xsl:if>
|
||||||
|
<?= t('sitemap-by') ?> <a target="_blank" rel="noopener nofollow" href="https://moeritz.io/">Tobias Möritz</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
</xsl:template>
|
||||||
|
</xsl:stylesheet>
|
||||||
87
site/plugins/kirby-seo/translations/de.yml
Normal file
87
site/plugins/kirby-seo/translations/de.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Heading Structure
|
||||||
|
heading-structure: Überschriftenstruktur
|
||||||
|
incorrect-heading-order: Deine Überschriftenstruktur hat eine falsche Abfolge und ist ungültig.
|
||||||
|
missing-h1-tag: Deine Überschriftenstruktur enthält keine H1 und ist ungültig.
|
||||||
|
multiple-h1-tags: Deine Überschriftenstruktur enthält mehr als eine H1 und ist ungültig.
|
||||||
|
|
||||||
|
# SEO Preview
|
||||||
|
seo-preview: Vorschau
|
||||||
|
seo-preview-for: Zeige mir
|
||||||
|
open-debugger: Sharing-Debugger öffnen
|
||||||
|
open-search-console: Search Console öffnen
|
||||||
|
|
||||||
|
# Robots
|
||||||
|
indicator-index: Indexierung erlaubt
|
||||||
|
indicator-any: Indexierung teilw. verboten
|
||||||
|
indicator-noindex: Indexierung verboten
|
||||||
|
robots: Richtlinien für Suchmaschinen & Crawler
|
||||||
|
robots-index: Indexierung
|
||||||
|
robots-index-help: Ob Suchmaschinen die Seite indexieren dürfen.
|
||||||
|
robots-follow: Links folgen
|
||||||
|
robots-follow-help: Ob Suchmaschinen Links auf dieser Seite folgen dürfen.
|
||||||
|
robots-archive: Archivierung
|
||||||
|
robots-archive-help: Ob Suchmaschinen zwischengespeicherte Versionen der Seite ausliefern dürfen.
|
||||||
|
robots-imageindex: Bilder-Indexierung
|
||||||
|
robots-imageindex-help: Ob Bilder dieser Seite in der Bildersuche angezeigt werden dürfen.
|
||||||
|
robots-snippet: Snippets
|
||||||
|
robots-snippet-help: Ob Suchmaschinen Textausschnitte aus der Seite anzeigen dürfen.
|
||||||
|
|
||||||
|
# Blueprint - Site/Common
|
||||||
|
metadata-site: Metadaten & SEO
|
||||||
|
global-meta-headline: Globale SEO-Einstellungen
|
||||||
|
global-meta-headline-help: |
|
||||||
|
Diese Einstellungen werden für alle Seiten verwendet, die keine eigenen Metadaten haben.
|
||||||
|
Du kannst sie für jede Seite überschreiben.
|
||||||
|
meta-title-template: Titel-Template
|
||||||
|
meta-title-template-help: |
|
||||||
|
Ein Template, das für alle Seitentitel verwendet werden soll.
|
||||||
|
"\{\{ title }}" wird durch den Titel der Seite ersetzt.
|
||||||
|
meta-description: Seitenbeschreibung
|
||||||
|
meta-description-help: Empfohlene Länge von max. 150 Zeichen. Wird verwendet, falls keine Seitenbeschreibung angegeben ist.
|
||||||
|
|
||||||
|
global-og-headline: Globale Open Graph-Einstellungen
|
||||||
|
global-og-headline-help: Stelle ein, wie deine Website erscheint, wenn sie auf sozialen Netzwerken wie Facebook oder Twitter geteilt wird.
|
||||||
|
og-title-template: Open Graph-Titel-Template
|
||||||
|
og-description: Open Graph-Beschreibung
|
||||||
|
og-site-name: Open Graph-Seitenname
|
||||||
|
og-image: Open Graph-Bild
|
||||||
|
og-image-help: Empfohlene Größe von 1200x630 Pixeln.
|
||||||
|
og-image-empty: Kein Open Graph-Bild ausgewählt
|
||||||
|
twitter-card-type: Twitter-Kartentyp
|
||||||
|
twitter-card-type-help: Der Twitter-Kartentyp bestimmt, wie deine Website aussieht, wenn sie auf Twitter geteilt wird. [Mehr Informationen](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards).
|
||||||
|
|
||||||
|
social-media-accounts: Social Media-Accounts
|
||||||
|
social-media-accounts-help: URLs bzw. @-Handles zu deinen Social Media-Accounts. Werden verwendet, um Links zu deinen Accounts in den Metadaten zu setzen.
|
||||||
|
|
||||||
|
# Blueprint - Page
|
||||||
|
meta-headline: SEO-Einstellungen
|
||||||
|
og-headline: Open Graph-Einstellungen
|
||||||
|
title-overwrite: Titel (Überschreiben)
|
||||||
|
summary: Karte mit quadratischem Bild
|
||||||
|
summary_large_image: Karte mit großem Bild
|
||||||
|
default-select: 'Standard:'
|
||||||
|
twitter-author: '@username des Autors auf Twitter'
|
||||||
|
inherit-settings: Einstellungen vererben
|
||||||
|
inherit-settings-help: |
|
||||||
|
Wähle aus, welche Einstellungen an Unterseiten vererbt werden sollen.
|
||||||
|
Dies kann z.B. hilfreich sein, wenn alle Beiträge eines Blogs ein eigenes Titel-Template haben sollen, welches vom Seiten-Standard abweicht. Alle Einstellungen lassen sich weiterhin in der Hauptseite überschreiben.
|
||||||
|
use-title-template: Titel-Template verwenden?
|
||||||
|
use-title-template-no: 'Nein - reiner Titel'
|
||||||
|
use-title-template-yes: 'Ja - mit Template'
|
||||||
|
use-title-template-help: Gibt an, ob das Titel-Template verwendet werden soll. Wird nicht vererbt.
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
google: Google
|
||||||
|
facebook: Facebook
|
||||||
|
slack: Slack
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
sitemap: Sitemap
|
||||||
|
sitemap-index: Sitemap-Index
|
||||||
|
sitemap-description: Dies ist die Sitemap für deine Website, die Suchmaschinen über die Seiten auf deiner Website informiert, die indexiert werden können.
|
||||||
|
sitemap-by: von
|
||||||
|
sitemap-changefreq: Änderungsfrequenz
|
||||||
|
sitemap-last-updated: Letzte Änderung
|
||||||
|
sitemap-priority: Priorität
|
||||||
|
sitemap-url: URL
|
||||||
|
sitemap-no-entries: Keine Einträge
|
||||||
87
site/plugins/kirby-seo/translations/en.yml
Normal file
87
site/plugins/kirby-seo/translations/en.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Heading Structure
|
||||||
|
heading-structure: Heading Structure
|
||||||
|
incorrect-heading-order: Your heading structure has an incorrect order and is invalid.
|
||||||
|
missing-h1-tag: Your heading structure does not contain an H1 and is invalid.
|
||||||
|
multiple-h1-tags: Your heading structure contains more than one H1 and is invalid.
|
||||||
|
|
||||||
|
# SEO Preview
|
||||||
|
seo-preview: Preview
|
||||||
|
seo-preview-for: Show me
|
||||||
|
open-debugger: Open Sharing Debugger
|
||||||
|
open-search-console: Open Search Console
|
||||||
|
|
||||||
|
# Robots
|
||||||
|
indicator-index: Indexing allowed
|
||||||
|
indicator-any: Indexing partly forbidden
|
||||||
|
indicator-noindex: Indexing forbidden
|
||||||
|
robots: Robots Directives
|
||||||
|
robots-index: Indexing
|
||||||
|
robots-index-help: Whether search engines may index this page.
|
||||||
|
robots-follow: Follow Links
|
||||||
|
robots-follow-help: Whether search engines may follow links on this page.
|
||||||
|
robots-archive: Archive
|
||||||
|
robots-archive-help: Whether search engines may archive this page.
|
||||||
|
robots-imageindex: Image Indexing
|
||||||
|
robots-imageindex-help: Whether search engines may index images on this page.
|
||||||
|
robots-snippet: Snippets
|
||||||
|
robots-snippet-help: Whether search engines may show text snippets of this page.
|
||||||
|
|
||||||
|
# Blueprint - Site/Common
|
||||||
|
metadata-site: Metadata & SEO
|
||||||
|
global-meta-headline: Global SEO Settings
|
||||||
|
global-meta-headline-help: |
|
||||||
|
These settings are used for all pages that do not have their own metadata.
|
||||||
|
You can override them for each page.
|
||||||
|
meta-title-template: Title Template
|
||||||
|
meta-title-template-help: |
|
||||||
|
A template to use for all page titles.
|
||||||
|
"\{\{ title }}" will be replaced with the page title.
|
||||||
|
meta-description: Page Description
|
||||||
|
meta-description-help: Recommended length of 150 characters max. Used if no page description is specified.
|
||||||
|
|
||||||
|
global-og-headline: Global Open Graph Settings
|
||||||
|
global-og-headline-help: Set how your website appears when shared on social networks like Facebook or Twitter.
|
||||||
|
og-title-template: Open Graph Title Template
|
||||||
|
og-description: Open Graph Description
|
||||||
|
og-site-name: Open Graph Site Name
|
||||||
|
og-image: Open Graph Image
|
||||||
|
og-image-help: Recommended size of 1200x630 pixels.
|
||||||
|
og-image-empty: No Open Graph Image selected
|
||||||
|
twitter-card-type: Twitter Card Type
|
||||||
|
twitter-card-type-help: Twitter Card Type determines how your website looks when shared on Twitter. [Read more](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards).
|
||||||
|
|
||||||
|
social-media-accounts: Social Media Accounts
|
||||||
|
social-media-accounts-help: URLs or @handles to your social media accounts. Used to put links to your accounts in the metadata.
|
||||||
|
|
||||||
|
# Blueprint - Page
|
||||||
|
meta-headline: SEO Settings
|
||||||
|
og-headline: Open Graph Settings
|
||||||
|
title-overwrite: Title (overwrite)
|
||||||
|
summary: Summary with Square Image
|
||||||
|
summary_large_image: Summary with Large Image
|
||||||
|
default-select: 'Default:'
|
||||||
|
twitter-author: 'Author @username on Twitter'
|
||||||
|
inherit-settings: Inherit Settings
|
||||||
|
inherit-settings-help: |
|
||||||
|
Select which settings should be inherited by subpages.
|
||||||
|
This can be helpful, for example, if all posts of a blog should have their own title template, which differs from the page default. All settings can still be overridden in the main page.
|
||||||
|
use-title-template: Use title template?
|
||||||
|
use-title-template-no: 'No - only title'
|
||||||
|
use-title-template-yes: 'Yes - with template'
|
||||||
|
use-title-template-help: Specifies whether the title template should be used. Will not be inherited.
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
google: Google
|
||||||
|
facebook: Facebook
|
||||||
|
slack: Slack
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
sitemap: Sitemap
|
||||||
|
sitemap-index: Sitemap Index
|
||||||
|
sitemap-description: This is the sitemap for your website that informs search engines about the pages on your website that can be indexed.
|
||||||
|
sitemap-by: by
|
||||||
|
sitemap-changefreq: Change Frequency
|
||||||
|
sitemap-last-updated: Last Updated
|
||||||
|
sitemap-priority: Priority
|
||||||
|
sitemap-url: URL
|
||||||
|
sitemap-no-entries: No entries
|
||||||
75
site/plugins/kirby-seo/translations/fr.yml
Normal file
75
site/plugins/kirby-seo/translations/fr.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Heading Structure
|
||||||
|
heading-structure: Structure des titres
|
||||||
|
incorrect-heading-order: Votre structure de titres a un ordre incorrect et est invalide.
|
||||||
|
missing-h1-tag: Votre structure de titres ne contient pas de balise H1 et est invalide.
|
||||||
|
multiple-h1-tags: Votre structure de titres contient plus d’une balise H1 et est invalide.
|
||||||
|
|
||||||
|
# SEO Preview
|
||||||
|
seo-preview: Aperçu
|
||||||
|
seo-preview-for: Montre-moi
|
||||||
|
open-debugger: Ouvrir le débogueur de partage
|
||||||
|
open-search-console: Ouvrir la Console de recherche
|
||||||
|
|
||||||
|
# Robots
|
||||||
|
indicator-index: Indexation autorisée
|
||||||
|
indicator-any: Indexation partiellement interdite
|
||||||
|
indicator-noindex: Indexation interdite
|
||||||
|
robots: Directives des robots
|
||||||
|
robots-index: Indexation
|
||||||
|
robots-index-help: Indique si les moteurs de recherche peuvent indexer cette page.
|
||||||
|
robots-follow: Suivre les liens
|
||||||
|
robots-follow-help: Indique si les moteurs de recherche peuvent suivre les liens de cette page.
|
||||||
|
robots-archive: Archiver
|
||||||
|
robots-archive-help: Indique si les moteurs de recherche peuvent archiver cette page.
|
||||||
|
robots-imageindex: Indexation des images
|
||||||
|
robots-imageindex-help: Indique si les moteurs de recherche peuvent indexer les images de cette page.
|
||||||
|
robots-snippet: Extraits
|
||||||
|
robots-snippet-help: Indique si les moteurs de recherche peuvent afficher des extraits de texte de cette page.
|
||||||
|
|
||||||
|
# Blueprint - Site/Common
|
||||||
|
metadata-site: Métadonnées et SEO
|
||||||
|
global-meta-headline: Paramètres SEO globaux
|
||||||
|
global-meta-headline-help: |
|
||||||
|
Ces paramètres sont utilisés pour toutes les pages qui n’ont pas leurs propres métadonnées.
|
||||||
|
Vous pouvez les remplacer pour chaque page.
|
||||||
|
meta-title-template: Modèle de titre
|
||||||
|
meta-title-template-help: |
|
||||||
|
Un modèle à utiliser pour tous les titres de page.
|
||||||
|
"\{\{ title }}" sera remplacé par le titre de la page.
|
||||||
|
meta-description: Description de la page
|
||||||
|
meta-description-help: Longueur recommandée de 150 caractères maximum. Utilisée si aucune description de page n’est spécifiée.
|
||||||
|
|
||||||
|
global-og-headline: Paramètres globaux Open Graph
|
||||||
|
global-og-headline-help: Définissez l’apparence de votre site web lorsqu’il est partagé sur les réseaux sociaux tels que Facebook ou Twitter.
|
||||||
|
og-title-template: Modèle de titre Open Graph
|
||||||
|
og-description: Description Open Graph
|
||||||
|
og-site-name: Nom du site Open Graph
|
||||||
|
og-image: Image Open Graph
|
||||||
|
og-image-help: Taille recommandée de 1200x630 pixels.
|
||||||
|
og-image-empty: Aucune image Open Graph sélectionnée
|
||||||
|
twitter-card-type: Type de carte Twitter
|
||||||
|
twitter-card-type-help: Le type de carte Twitter détermine l’apparence de votre site web lorsqu’il est partagé sur Twitter. [En savoir plus](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards).
|
||||||
|
|
||||||
|
social-media-accounts: Comptes de réseaux sociaux
|
||||||
|
social-media-accounts-help: URLs ou @nom_utilisateur de vos comptes de réseaux sociaux. Utilisés pour mettre des liens vers vos comptes dans les métadonnées.
|
||||||
|
# Blueprint - Page
|
||||||
|
meta-headline: Paramètres SEO
|
||||||
|
og-headline: Paramètres Open Graph
|
||||||
|
title-overwrite: Titre (remplacement)
|
||||||
|
summary: Résumé avec une image carrée
|
||||||
|
summary_large_image: Résumé avec une grande image
|
||||||
|
default-select: 'Par défaut :'
|
||||||
|
twitter-author: 'Auteur @nom_utilisateur sur Twitter'
|
||||||
|
inherit-settings: Hériter des paramètres
|
||||||
|
inherit-settings-help: |
|
||||||
|
Sélectionnez les paramètres à hériter par les sous-pages.
|
||||||
|
Cela peut être utile, par exemple, si tous les articles d’un blog doivent avoir leur propre modèle de titre, différent de celui de la page par défaut. Tous les paramètres peuvent toujours être remplacés dans la page principale.
|
||||||
|
use-title-template: Utiliser le modèle de titre ?
|
||||||
|
use-title-template-no: 'Non - seulement le titre'
|
||||||
|
use-title-template-yes: 'Oui - avec le modèle'
|
||||||
|
use-title-template-help: Indique si le modèle de titre doit être utilisé. Ne sera pas hérité.
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
google: Google
|
||||||
|
facebook: Facebook
|
||||||
|
slack: Slack
|
||||||
87
site/plugins/kirby-seo/translations/pt_PT.yml
Normal file
87
site/plugins/kirby-seo/translations/pt_PT.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Heading Structure
|
||||||
|
heading-structure: Estrutura de Títulos
|
||||||
|
incorrect-heading-order: A estrutura de títulos tem uma ordem incorrecta e é inválida.
|
||||||
|
missing-h1-tag: A estrutura de títulos não contém uma tag H1 e é inválida.
|
||||||
|
multiple-h1-tags: A estrutura de títulos contém mais do que uma tag H1 e é inválida.
|
||||||
|
|
||||||
|
# SEO Preview
|
||||||
|
seo-preview: Pré-visualização
|
||||||
|
seo-preview-for: Mostrar
|
||||||
|
open-debugger: Abrir Sharing Debugger
|
||||||
|
open-search-console: Abrir Search Console
|
||||||
|
|
||||||
|
# Robots
|
||||||
|
indicator-index: Indexação permitida
|
||||||
|
indicator-any: Indexação parcialmente proibida
|
||||||
|
indicator-noindex: Indexação proibida
|
||||||
|
robots: Diretivas Robots
|
||||||
|
robots-index: Indexação
|
||||||
|
robots-index-help: Se os motores de pesquisa podem indexar esta página.
|
||||||
|
robots-follow: Seguir Links
|
||||||
|
robots-follow-help: Se os motores de pesquisa podem seguir links nesta página.
|
||||||
|
robots-archive: Arquivo
|
||||||
|
robots-archive-help: Se os motores de pesquisa podem arquivar esta página.
|
||||||
|
robots-imageindex: Indexação de Imagens
|
||||||
|
robots-imageindex-help: Se os motores de pesquisa podem indexar imagens desta página.
|
||||||
|
robots-snippet: Snippets
|
||||||
|
robots-snippet-help: Se os motores de pesquisa podem mostrar snippets de texto desta página.
|
||||||
|
|
||||||
|
# Blueprint - Site/Common
|
||||||
|
metadata-site: Metadados & SEO
|
||||||
|
global-meta-headline: Configurações Globais SEO
|
||||||
|
global-meta-headline-help: |
|
||||||
|
Estas configurações são usadas para todas as páginas que não têm os seus próprios metadados.
|
||||||
|
Pode substituí-las em cada página.
|
||||||
|
meta-title-template: Template de Título
|
||||||
|
meta-title-template-help: |
|
||||||
|
Template para usar em todos os títulos de páginas.
|
||||||
|
"\{\{ title }}" será substituído pelo título da página.
|
||||||
|
meta-description: Descrição de Página
|
||||||
|
meta-description-help: Recomendado um tamanho de 150 caracteres no máximo. Usada se nenhuma descrição de página for especificada.
|
||||||
|
|
||||||
|
global-og-headline: Configurações Globais Open Graph
|
||||||
|
global-og-headline-help: Defina como o seu site aparece quando é partilhado em redes sociais como o Facebook ou o Twitter.
|
||||||
|
og-title-template: Template de Título Open Graph
|
||||||
|
og-description: Descrição Open Graph
|
||||||
|
og-site-name: Nome do Site Open Graph
|
||||||
|
og-image: Imagem Open Graph
|
||||||
|
og-image-help: Tamanho recomendado de 1200x630 pixels.
|
||||||
|
og-image-empty: Nenhuma Imagem Open Graph selecionada
|
||||||
|
twitter-card-type: Tipo de Twitter Card
|
||||||
|
twitter-card-type-help: O tipo de Twitter Card determina a aparência do seu site quando é partilhado no Twitter. [Leia mais](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards).
|
||||||
|
|
||||||
|
social-media-accounts: Contas de Redes Sociais
|
||||||
|
social-media-accounts-help: URLs ou @handles para as suas contas de redes sociais. Usado para colocar links para as suas contas nos metadados.
|
||||||
|
|
||||||
|
# Blueprint - Page
|
||||||
|
meta-headline: Configurações SEO
|
||||||
|
og-headline: Configurações Open Graph
|
||||||
|
title-overwrite: Título (substituir)
|
||||||
|
summary: Resumo com Imagem Quadrada
|
||||||
|
summary_large_image: Resumo com Imagem Grande
|
||||||
|
default-select: 'Por defeito:'
|
||||||
|
twitter-author: 'Autor @username no Twitter'
|
||||||
|
inherit-settings: Herdar Configurações
|
||||||
|
inherit-settings-help: |
|
||||||
|
Selecione quais as configurações que devem ser herdadas pelas subpáginas.
|
||||||
|
Isto pode ser útil, por exemplo, se todos os posts de um blog tiverem o seu próprio template de título, que pode ser diferente do pré-configurado na página. Todas as configurações continuam a poder ser substituídas na página principal.
|
||||||
|
use-title-template: Usar o template de título?
|
||||||
|
use-title-template-no: 'Não - apenas o título'
|
||||||
|
use-title-template-yes: 'Sim - com template'
|
||||||
|
use-title-template-help: Especifica se o template de título deve ser usado. Não será herdado.
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
google: Google
|
||||||
|
facebook: Facebook
|
||||||
|
slack: Slack
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
sitemap: Sitemap
|
||||||
|
sitemap-index: Índice Sitemap
|
||||||
|
sitemap-description: Este é o sitemap do site que informa os motores de pesquisa sobre as páginas que podem ser indexadas.
|
||||||
|
sitemap-by: por
|
||||||
|
sitemap-changefreq: Frequência de Mudança
|
||||||
|
sitemap-last-updated: Última Atualização
|
||||||
|
sitemap-priority: Prioridade
|
||||||
|
sitemap-url: URL
|
||||||
|
sitemap-no-entries: Sem registos
|
||||||
87
site/plugins/kirby-seo/translations/tr.yml
Normal file
87
site/plugins/kirby-seo/translations/tr.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Başlık Yapısı
|
||||||
|
heading-structure: Başlık Yapısı
|
||||||
|
incorrect-heading-order: Başlık yapınız yanlış bir sıraya sahip ve geçersizdir.
|
||||||
|
missing-h1-tag: Başlık yapınızda bir H1 etiketi bulunmuyor ve geçersizdir.
|
||||||
|
multiple-h1-tags: Başlık yapınızda birden fazla H1 etiketi bulunuyor ve geçersizdir.
|
||||||
|
|
||||||
|
# SEO Önizleme
|
||||||
|
seo-preview: Önizleme
|
||||||
|
seo-preview-for: Bana göster
|
||||||
|
open-debugger: Paylaşım Hata Ayıklayıcıyı Aç
|
||||||
|
open-search-console: Arama Konsolunu Aç
|
||||||
|
|
||||||
|
# Robotlar
|
||||||
|
indicator-index: İndekslemeye izin verildi
|
||||||
|
indicator-any: İndeksleme kısmen yasaklandı
|
||||||
|
indicator-noindex: İndeksleme yasaklandı
|
||||||
|
robots: Robot Talimatları
|
||||||
|
robots-index: İndeksleme
|
||||||
|
robots-index-help: Arama motorlarının bu sayfayı indeksleyip indekslemeyeceğini belirtir.
|
||||||
|
robots-follow: Bağlantıları Takip Et
|
||||||
|
robots-follow-help: Arama motorlarının bu sayfadaki bağlantıları takip edip etmeyeceğini belirtir.
|
||||||
|
robots-archive: Arşivle
|
||||||
|
robots-archive-help: Arama motorlarının bu sayfayı arşivleyip arşivlemeyeceğini belirtir.
|
||||||
|
robots-imageindex: İmaj İndeksleme
|
||||||
|
robots-imageindex-help: Arama motorlarının bu sayfadaki görüntüleri indeksleyip indekslemeyeceğini belirtir.
|
||||||
|
robots-snippet: Özetler
|
||||||
|
robots-snippet-help: Arama motorlarının bu sayfanın metin özetlerini gösterip göstermeyeceğini belirtir.
|
||||||
|
|
||||||
|
# Şablona Dayalı - Site/Ortak
|
||||||
|
metadata-site: Meta Verileri ve SEO
|
||||||
|
global-meta-headline: Genel SEO Ayarları
|
||||||
|
global-meta-headline-help: |
|
||||||
|
Bu ayarlar, kendi meta verileri olmayan tüm sayfalar için kullanılır.
|
||||||
|
Her sayfa için bunları geçersiz kılabilirsiniz.
|
||||||
|
meta-title-template: Başlık Şablonu
|
||||||
|
meta-title-template-help: |
|
||||||
|
Tüm sayfa başlıkları için kullanılacak bir şablon.
|
||||||
|
"\{\{ başlık }}" sayfa başlığı ile değiştirilecektir.
|
||||||
|
meta-description: Sayfa Açıklaması
|
||||||
|
meta-description-help: Maksimum 150 karakter uzunluğunda önerilen bir sayfa açıklamasıdır. Sayfa açıklaması belirtilmemişse kullanılır.
|
||||||
|
|
||||||
|
global-og-headline: Genel Açık Grafik (OG) Ayarları
|
||||||
|
global-og-headline-help: Web sitenizin Facebook veya Twitter gibi sosyal ağlarda paylaşıldığında nasıl göründüğünü ayarlar.
|
||||||
|
og-title-template: Açık Grafik (OG) Başlık Şablonu
|
||||||
|
og-description: Açık Grafik (OG) Açıklaması
|
||||||
|
og-site-name: Açık Grafik (OG) Site Adı
|
||||||
|
og-image: Açık Grafik (OG) Görseli
|
||||||
|
og-image-help: Önerilen boyut 1200x630 pikseldir.
|
||||||
|
og-image-empty: Boş Açık Grafik (OG) Görseli
|
||||||
|
twitter-card-type: Twitter Kart Türü
|
||||||
|
twitter-card-type-help: Twitter Kart Türü, Twitter'da paylaşıldığında web sitenizin nasıl göründüğünü belirler. [Daha fazla bilgi](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards).
|
||||||
|
|
||||||
|
social-media-accounts: Sosyal Medya Hesapları
|
||||||
|
social-media-accounts-help: Sosyal medya hesaplarınızın URL'leri veya @kullanici_adi. Meta verilerinde hesaplarınıza bağlantı eklemek için kullanılır.
|
||||||
|
|
||||||
|
# Şablona Dayalı - Sayfa
|
||||||
|
meta-headline: SEO Ayarları
|
||||||
|
og-headline: Açık Grafik (OG) Ayarları
|
||||||
|
title-overwrite: Başlık (üzerine yaz)
|
||||||
|
summary: Kare İmaj ile Özet
|
||||||
|
summary_large_image: Büyük İmaj ile Özet
|
||||||
|
default-select: 'Varsayılan:'
|
||||||
|
twitter-author: Twitter'da @kullanici_adi
|
||||||
|
inherit-settings: Ayarları Miras Al
|
||||||
|
inherit-settings-help: |
|
||||||
|
Alt sayfalar tarafından miras alınacak ayarları seçin.
|
||||||
|
Örneğin, bir blogun tüm yazılarının sayfa varsayılanından farklı bir başlık şablonuna sahip olması gerekiyorsa bu yardımcı olabilir. Tüm ayarlar ana sayfada hala geçersiz kılınabilir.
|
||||||
|
use-title-template: Başlık şablonunu kullan?
|
||||||
|
use-title-template-no: 'Hayır - sadece başlık'
|
||||||
|
use-title-template-yes: 'Evet - şablonla'
|
||||||
|
use-title-template-help: Başlık şablonunun kullanılıp kullanılmayacağını belirtir. Miras alınmayacaktır.
|
||||||
|
|
||||||
|
# Etiketler
|
||||||
|
google: Google
|
||||||
|
facebook: Facebook
|
||||||
|
slack: Slack
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
sitemap: Site Haritası
|
||||||
|
sitemap-index: Site Haritası Dizini
|
||||||
|
sitemap-description: Web sitenizdeki dizine eklenebilecek sayfalar hakkında arama motorlarını bilgilendiren web sitenizin site haritasıdır.
|
||||||
|
sitemap-by: tarafından
|
||||||
|
sitemap-changefreq: Değişim Sıklığı
|
||||||
|
sitemap-last-updated: Son Güncelleme
|
||||||
|
sitemap-priority: Öncelik
|
||||||
|
sitemap-url: URL
|
||||||
|
sitemap-no-entries: Giriş yok
|
||||||
|
|
@ -51,6 +51,7 @@ $entryTopPos ??= 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<?php snippet('seo/head'); ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- FAVICON -->
|
<!-- FAVICON -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue