SEO : add tombi mori plugin

This commit is contained in:
isUnknown 2025-05-13 09:03:14 +02:00
parent df2843123f
commit 8f9e75126e
64 changed files with 3719 additions and 44 deletions

View file

@ -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
View file

@ -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"
} }

View file

@ -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

View file

@ -7,3 +7,6 @@ tabs:
label: Liste label: Liste
type: pages type: pages
template: author template: author
seo:
extends: seo/page
label: Indexation

View file

@ -41,3 +41,6 @@ tabs:
type: email type: email
sendBtn: sendBtn:
type: send-button type: send-button
seo:
extends: seo/page
label: Indexation

View file

@ -44,3 +44,7 @@ tabs:
- quote - quote
metaTab: tabs/meta metaTab: tabs/meta
seo:
extends: seo/page
label: Indexation

View file

@ -16,3 +16,6 @@ tabs:
type: fields type: fields
fields: fields:
body: fields/body body: fields/body
seo:
extends: seo/page
label: Indexation

View file

@ -44,3 +44,7 @@ tabs:
paramsTab: tabs/params paramsTab: tabs/params
metaTab: tabs/meta metaTab: tabs/meta
seo:
extends: seo/page
label: Indexation

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm run build && git add index.css index.js

View file

@ -0,0 +1 @@
20

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

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

View file

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

View 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

View 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

View file

@ -0,0 +1,31 @@
<?php
use Kirby\Cms\App;
use Kirby\Toolkit\Str;
return function (App $kirby) {
$blueprint = [
'type' => 'files',
'multiple' => false,
'uploads' => [],
'query' => 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;
};

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

View file

@ -0,0 +1,49 @@
<?php
use Kirby\Cms\App;
return function (App $kirby) {
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
return [
'type' => 'hidden'
];
}
$fields = [
'robotsHeadline' => [
'label' => '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,
];
};

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

View 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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View file

@ -0,0 +1,6 @@
export default {
plugins: {
'postcss-logical': {},
autoprefixer: {}
}
}

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

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

View file

@ -0,0 +1,8 @@
<?php
$siteSchema ??= true;
$pageSchema ??= true;
foreach (array_merge($siteSchema ? $site->schemas() : [], $pageSchema ? $page->schemas() : []) as $schema) {
echo $schema;
}

View file

@ -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>

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

View file

@ -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>

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

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

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

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

View file

@ -0,0 +1,3 @@
<?php
go($page->url() . '.xml');

View file

@ -0,0 +1,5 @@
<?php
use tobimori\Seo\Sitemap\SitemapIndex;
echo SitemapIndex::instance()->render($page);

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

View 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

View 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

View 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 dune 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 nont 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 nest spécifiée.
global-og-headline: Paramètres globaux Open Graph
global-og-headline-help: Définissez lapparence de votre site web lorsquil 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 lapparence de votre site web lorsquil 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 dun 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

View 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

View 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ı
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: ık Grafik (OG) Başlık Şablonu
og-description: ık Grafik (OG) Açıklaması
og-site-name: ık Grafik (OG) Site Adı
og-image: ı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: ı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

View file

@ -51,6 +51,7 @@ $entryTopPos ??= 20;
} }
} }
</script> </script>
<?php snippet('seo/head'); ?>
<?php endif ?> <?php endif ?>
<!-- FAVICON --> <!-- FAVICON -->