diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..e05cc5c8
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+# This file is for unifying the coding style for different editors and IDEs.
+# More information at https://editorconfig.org
+
+root = true
+
+[*]
+charset = utf-8
+indent_size = 4
+indent_style = space
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.yaml]
+indent_size = 2
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..240381ad
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+# Path-based git attributes
+# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
+
+# Ignore all tests and documentation with "export-ignore".
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.github export-ignore
+/.gitignore export-ignore
+/.php.cs export-ignore
+/.travis.yml export-ignore
+/phpunit.xml export-ignore
+/tests export-ignore
\ No newline at end of file
diff --git a/.github/workflows/php-cs-fixer.yaml b/.github/workflows/php-cs-fixer.yaml
new file mode 100644
index 00000000..f55d1fa8
--- /dev/null
+++ b/.github/workflows/php-cs-fixer.yaml
@@ -0,0 +1,23 @@
+name: Check & fix styling
+
+on: [push]
+
+jobs:
+ php-cs-fixer:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Run PHP CS Fixer
+ uses: docker://oskarstark/php-cs-fixer-ga
+ with:
+ args: --config=.php_cs.dist.php --allow-risky=yes
+
+ - name: Commit changes
+ uses: stefanzweifel/git-auto-commit-action@v4
+ with:
+ commit_message: Fix styling
diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml
new file mode 100644
index 00000000..44bab9c2
--- /dev/null
+++ b/.github/workflows/run-tests.yaml
@@ -0,0 +1,39 @@
+name: Tests
+
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ php_tests:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [7.4, 8.0]
+ laravel: [8.*, 7.*]
+ dependency-version: [prefer-lowest, prefer-stable]
+ include:
+ - laravel: 8.*
+ testbench: 6.*
+ - laravel: 7.*
+ testbench: 5.*
+
+ name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
+ coverage: none
+
+ - name: Install dependencies
+ run: |
+ composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
+ composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest
+
+ - name: Run Tests
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..cbdf8444
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.DS_Store
+.idea
+.env
+.phpunit.result.cache
+.php_cs.cache
+.php-cs-fixer.cache
+coverage
+node_modules
+tests/__fixtures__/content/*.yaml
+tests/__fixtures__/users/*.yaml
+vendor
diff --git a/.php_cs.dist.php b/.php_cs.dist.php
new file mode 100644
index 00000000..d612a548
--- /dev/null
+++ b/.php_cs.dist.php
@@ -0,0 +1,35 @@
+in([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->name('*.php')
+ ->notName('*.blade.php')
+ ->ignoreDotFiles(true)
+ ->ignoreVCS(true);
+
+return (new PhpCsFixer\Config())
+ ->setRules([
+ '@PSR2' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'ordered_imports' => ['sort_algorithm' => 'alpha'],
+ 'no_unused_imports' => true,
+ 'not_operator_with_successor_space' => true,
+ 'trailing_comma_in_multiline' => true,
+ 'phpdoc_scalar' => true,
+ 'unary_operator_spaces' => true,
+ 'binary_operator_spaces' => true,
+ 'blank_line_before_statement' => [
+ 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
+ ],
+ 'phpdoc_single_line_var_spacing' => true,
+ 'phpdoc_var_without_name' => true,
+ 'method_argument_space' => [
+ 'on_multiline' => 'ensure_fully_multiline',
+ 'keep_multiple_spaces_after_comma' => true,
+ ],
+ 'single_trait_insert_per_statement' => true,
+ ])
+ ->setFinder($finder);
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..9e0c7516
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# Advanced SEO
+An advanced SEO addon for Statamic
+
+## Credits
+Developed by [Michael Aerni](https://www.michaelaerni.ch)
diff --git a/composer.json b/composer.json
new file mode 100644
index 00000000..55995196
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,64 @@
+{
+ "name": "aerni/advanced-seo",
+ "description": "An advanced SEO addon for Statamic.",
+ "keywords": [
+ "statamic",
+ "seo"
+ ],
+ "homepage": "https://github.com/aerni/statamic-advanced-seo",
+ "license": "proprietary",
+ "authors": [
+ {
+ "name": "Michael Aerni",
+ "email": "hello@michaelaerni.ch",
+ "homepage": "https://michaelaerni.ch",
+ "role": "Developer"
+ }
+ ],
+ "require": {
+ "php": "^7.4 | ^8.0",
+ "spatie/laravel-ray": "^1.24",
+ "statamic/cms": "^3.1"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^2.19",
+ "nunomaduro/collision": "^5.4",
+ "orchestra/testbench": "^6.17",
+ "phpunit/phpunit": "^9.5"
+ },
+ "autoload": {
+ "psr-4": {
+ "Aerni\\AdvancedSeo\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Aerni\\AdvancedSeo\\Tests\\": "tests"
+ },
+ "classmap": [
+ "tests/TestCase.php"
+ ]
+ },
+ "scripts": {
+ "test": "vendor/bin/phpunit",
+ "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes"
+ },
+ "config": {
+ "optimize-autoloader": true,
+ "preferred-install": "dist",
+ "sort-packages": true
+ },
+ "extra": {
+ "statamic": {
+ "name": "Advanced SEO",
+ "description": "An advanced SEO addon for Statamic."
+ },
+ "laravel": {
+ "providers": [
+ "Aerni\\AdvancedSeo\\ServiceProvider"
+ ]
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
+}
diff --git a/config/advanced-seo.php b/config/advanced-seo.php
new file mode 100644
index 00000000..ebf0f5b1
--- /dev/null
+++ b/config/advanced-seo.php
@@ -0,0 +1,120 @@
+ true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Social Images
+ |--------------------------------------------------------------------------
+ |
+ | Configure the options for your social images.
+ |
+ */
+
+ 'social_images' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Social Images Generator
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Social Images Generator?
+ | This requires Puppeteer and Browsershot.
+ |
+ */
+
+ 'generator' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Open Graph Images
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Open Graph Images settings?
+ |
+ */
+
+ 'open_graph' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Twitter Images
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Twitter Images settings?
+ |
+ */
+
+ 'twitter' => true,
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Trackers
+ |--------------------------------------------------------------------------
+ |
+ | Configure the options for your trackers
+ |
+ */
+
+ 'trackers' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Site Verification
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Site Verification settings?
+ |
+ */
+
+ 'site_verification' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fathom Analytics
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Fathom Analytics settings?
+ |
+ */
+
+ 'fathom' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cloudflare Analytics
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Cloudflare Analytics settings?
+ |
+ */
+
+ 'cloudflare' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Google Tag Manager
+ |--------------------------------------------------------------------------
+ |
+ | Do you want to enable the Google Tag Manager settings?
+ |
+ */
+
+ 'google_tag_manager' => true,
+
+ ],
+
+];
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..3ec5f7e5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+ "private": true,
+ "scripts": {
+ "development": "mix",
+ "watch": "mix watch",
+ "watch-poll": "mix watch -- --watch-options-poll=1000",
+ "hot": "mix watch --hot",
+ "production": "mix --production"
+ },
+ "dependencies": {
+ "axios": "^0.21.1",
+ "tailwindcss": "^2.1.2",
+ "vue": "^2.6.12"
+ },
+ "devDependencies": {
+ "laravel-mix": "^6.0.19",
+ "vue-loader": "^15.9.7",
+ "vue-template-compiler": "^2.6.12"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 00000000..2034c361
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ ./tests
+
+
+
+
+ ./src
+
+
+
diff --git a/resources/css/advanced-seo.css b/resources/css/advanced-seo.css
new file mode 100644
index 00000000..b5c61c95
--- /dev/null
+++ b/resources/css/advanced-seo.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/resources/js/advanced-seo.js b/resources/js/advanced-seo.js
new file mode 100644
index 00000000..e69de29b
diff --git a/resources/views/seo.antlers.html b/resources/views/seo.antlers.html
new file mode 100644
index 00000000..e865e4d7
--- /dev/null
+++ b/resources/views/seo.antlers.html
@@ -0,0 +1,28 @@
+{{#
+ @name SEO
+ @desc The SEO partial rendered in the
of your page. You don't need to use this file if you plan on using an addon for SEO.
+#}}
+
+{{# Basic Meta Tags #}}
+{{ partial:snippets/seo/basic }}
+
+{{# Favicons #}}
+{{ partial:snippets/seo/favicons }}
+
+{{# Knowledge Graph #}}
+{{ partial:snippets/seo/knowledge_graph }}
+
+{{# Open Graph #}}
+{{ partial:snippets/seo/open_graph }}
+
+{{# Twitter #}}
+{{ partial:snippets/seo/twitter }}
+
+{{# Canonical URL #}}
+{{ partial:snippets/seo/canonical_url }}
+
+{{# Robots Indexing #}}
+{{ partial:snippets/seo/indexing }}
+
+{{# Trackers #}}
+{{ partial:snippets/seo/trackers }}
diff --git a/resources/views/sitemap.antlers.html b/resources/views/sitemap.antlers.html
new file mode 100644
index 00000000..71c8aed6
--- /dev/null
+++ b/resources/views/sitemap.antlers.html
@@ -0,0 +1,20 @@
+{{#
+ @name Sitemap
+ @desc The sitemap template that renders a SEO friendy sitemap.xml.
+#}}
+
+{{ xml_header }}
+
+ {{ seo:sitemap_collections }}
+ {{ collection from="{handle}" seo_noindex:isnt="true" as="results" }}
+ {{ results }}
+
+ {{ permalink }}
+ {{ updated_at format="Y-m-d"}}
+ {{ sitemap_change_frequency ? sitemap_change_frequency : 'weekly' }}
+ {{ sitemap_priority ? sitemap_priority : '0.5' }}
+
+ {{ /results }}
+ {{ /collection }}
+ {{ /seo:sitemap_collections }}
+
diff --git a/resources/views/snippets/_basic.antlers.html b/resources/views/snippets/_basic.antlers.html
new file mode 100644
index 00000000..aa5ff7ec
--- /dev/null
+++ b/resources/views/snippets/_basic.antlers.html
@@ -0,0 +1,12 @@
+{{# Meta Title #}}
+
+ {{ yield:seo_title }}
+ {{ seo_title ?? title }}
+ {{ seo:title_separator ?? " | " }}
+ {{ seo:site_name ?? config:app:name }}
+
+
+{{# Meta Description #}}
+{{ if seo_description }}
+
+{{ /if }}
diff --git a/resources/views/snippets/_canonical_url.antlers.html b/resources/views/snippets/_canonical_url.antlers.html
new file mode 100644
index 00000000..ee53a342
--- /dev/null
+++ b/resources/views/snippets/_canonical_url.antlers.html
@@ -0,0 +1,16 @@
+{{# hreflang Links #}}
+{{ if ! seo_noindex && seo_canonical_type === 'entry' && current_full_url === permalink }}
+ {{ locales }}
+
+ {{ /locales }}
+{{ /if }}
+
+{{ if ! seo_noindex }}
+ {{ if seo_canonical_type === 'current' }}
+
+ {{ elseif seo_canonical_type === 'external' }}
+
+ {{ elseif seo_canonical_type === 'entry' }}
+
+ {{ /if }}
+{{ /if }}
diff --git a/resources/views/snippets/_favicons.antlers.html b/resources/views/snippets/_favicons.antlers.html
new file mode 100644
index 00000000..2b01cec7
--- /dev/null
+++ b/resources/views/snippets/_favicons.antlers.html
@@ -0,0 +1,25 @@
+{{#
+ @name Favicons
+ @desc The favicons snippet for the `` to include Peak generated favicons.
+#}}
+
+{{ if seo:generate_favicons }}
+ {{# The main favicon SVG #}}
+
+
+ {{# Single color Safari mask-icon #}}
+
+
+ {{# The 180x180px PNG favicon for iOS devices #}}
+ {{ if seo:favicon_ios_override }}
+
+ {{ else }}
+
+ {{ /if }}
+
+ {{# The manifest.json that includes the 512x512px PNG favicon for Android devices #}}
+
+
+ {{# The background color for Android Chrome #}}
+
+{{ /if }}
diff --git a/resources/views/snippets/_indexing.antlers.html b/resources/views/snippets/_indexing.antlers.html
new file mode 100644
index 00000000..3be5d0f0
--- /dev/null
+++ b/resources/views/snippets/_indexing.antlers.html
@@ -0,0 +1,15 @@
+{{ if environment === "production" }}
+
+ {{ if seo_noindex && seo_nofollow }}
+
+ {{ elseif seo_nofollow }}
+
+ {{ elseif seo_noindex }}
+
+ {{ /if }}
+
+{{ else }}
+
+
+
+{{ /if }}
diff --git a/resources/views/snippets/_knowledge_graph.antlers.html b/resources/views/snippets/_knowledge_graph.antlers.html
new file mode 100644
index 00000000..b889e896
--- /dev/null
+++ b/resources/views/snippets/_knowledge_graph.antlers.html
@@ -0,0 +1,58 @@
+{{# Global JSON-LD Schema #}}
+{{ if seo:json_ld_type === 'organization' }}
+
+{{ /if }}
+
+{{# Global JSON-LD Schema #}}
+{{ if seo:json_ld_type === 'person' }}
+
+{{ /if }}
+
+{{# Global JSON-LD Schema #}}
+{{ if seo:json_ld_type === 'custom' }}
+
+{{ /if }}
+
+{{# Per Entry JSON-LD Schema #}}
+{{ if json_ld }}
+
+{{ /if }}
+
+{{# Global JSON-LD Breadcrumbs #}}
+{{ if seo:breadcrumbs && segment_1 }}
+
+{{ /if }}
diff --git a/resources/views/snippets/_open_graph.antlers.html b/resources/views/snippets/_open_graph.antlers.html
new file mode 100644
index 00000000..0da90493
--- /dev/null
+++ b/resources/views/snippets/_open_graph.antlers.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+{{ if og_description or seo_description }}
+
+{{ /if }}
+
+{{ if og_image or seo:og_image }}
+
+{{ /if }}
diff --git a/resources/views/snippets/_trackers.antlers.html b/resources/views/snippets/_trackers.antlers.html
new file mode 100644
index 00000000..4f6f8796
--- /dev/null
+++ b/resources/views/snippets/_trackers.antlers.html
@@ -0,0 +1,41 @@
+{{ if environment === "production" }}
+
+ {{# Google Site Verification #}}
+ {{ if seo:use_google_site_verification }}
+
+ {{ /if }}
+
+ {{# Fathom #}}
+ {{ if seo:use_fathom }}
+
+ {{ /if }}
+
+ {{# Cloudflare Web Analytics #}}
+ {{ if seo:use_cloudflare_web_analytics }}
+
+ {{ /if }}
+
+ {{# Google Tag Manager #}}
+ {{ if seo:use_google_tag_manager }}
+
+
+ {{# Yield this section in all your layouts after the opening #}}
+ {{ section:seo_body }}
+
+ {{ /section:seo_body }}
+ {{ /if }}
+
+{{ /if }}
diff --git a/resources/views/snippets/_twitter.antlers.html b/resources/views/snippets/_twitter.antlers.html
new file mode 100644
index 00000000..77f23e7d
--- /dev/null
+++ b/resources/views/snippets/_twitter.antlers.html
@@ -0,0 +1,26 @@
+
+
+
+{{ if twitter_description or seo_description }}
+
+{{ /if }}
+
+{{ if seo:twitter_handle }}
+
+{{ /if }}
+
+{{ if twitter_image }}
+ {{ twitter_image }}
+
+ {{ if alt }}
+
+ {{ /if }}
+ {{ /twitter_image }}
+{{ elseif seo:twitter_image }}
+ {{ seo:twitter_image }}
+
+ {{ if alt }}
+
+ {{ /if }}
+ {{ /seo:twitter_image }}
+{{ /if }}
diff --git a/resources/views/social_images/layout.antlers.html b/resources/views/social_images/layout.antlers.html
new file mode 100644
index 00000000..924311f1
--- /dev/null
+++ b/resources/views/social_images/layout.antlers.html
@@ -0,0 +1,33 @@
+{{#
+ @name Social Images
+ @desc The template for generating Social Images. By default two sizes, one for the regular OG protocol and one for Twitter.
+#}}
+
+
+
+
+
+
+
+
+
+
+ Social Images / {{ get_content:id }}{{ title }}{{ /get_content:id }}
+
+
+
+
+
+
+
+
+
+ {{ partial:social_images/template selector="og" }}
+ {{ partial:social_images/template selector="twitter" }}
+
+
+
+
+
diff --git a/resources/views/social_images/template.antlers.html b/resources/views/social_images/template.antlers.html
new file mode 100644
index 00000000..4a0aae00
--- /dev/null
+++ b/resources/views/social_images/template.antlers.html
@@ -0,0 +1,14 @@
+
+
+ {{ svg src="logo-primary.svg" a11y="Apotheker" class="fill-current" }}
+
+
diff --git a/src/Blueprints/Sections/FaviconsSection.php b/src/Blueprints/Sections/FaviconsSection.php
new file mode 100644
index 00000000..c55b7313
--- /dev/null
+++ b/src/Blueprints/Sections/FaviconsSection.php
@@ -0,0 +1,250 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'Favicons',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ if (config('advanced-seo.favicons', true)) {
+ $fields->push($this->faviconsSection());
+ }
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function faviconsSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_favicons',
+ 'field' => [
+ 'type' => 'section',
+ 'listable' => 'hidden',
+ 'display' => 'Favicons',
+ 'instructions' => 'Automatically generate favicons for different devices. This requires the [PHP Imagick Extension](https://github.com/Imagick/imagick).',
+ ],
+ ],
+ [
+ 'handle' => 'generate_favicons',
+ 'field' => [
+ 'display' => 'Generate Favicons',
+ 'type' => 'toggle',
+ 'icon' => 'toggle',
+ 'instructions' => 'Activate to generate favicons.',
+ 'listable' => 'hidden',
+ ],
+ ],
+ [
+ 'handle' => 'favicon_svg',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => false,
+ 'listable' => 'hidden',
+ 'folder' => 'favicons',
+ 'display' => 'Favicon (SVG)',
+ 'instructions' => 'Add your favicon as SVG file.',
+ 'width' => 50,
+ 'validate' => [
+ 'required_if:generate_favicons,true',
+ 'image',
+ 'mimes:svg',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_favicon_colors',
+ 'field' => [
+ 'type' => 'section',
+ 'listable' => 'hidden',
+ 'display' => 'Favicon Colors',
+ 'instructions' => 'Configure your favicon colors.',
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_safari_color',
+ 'field' => [
+ 'theme' => 'nano',
+ 'lock_opacity' => true,
+ 'default_color_mode' => 'HEXA',
+ 'color_modes' => [
+ 'hex',
+ ],
+ 'display' => 'Safari (mask-icon)',
+ 'type' => 'color',
+ 'icon' => 'color',
+ 'listable' => 'hidden',
+ 'instructions' => 'The color of your favicon in Safari.',
+ 'width' => 50,
+ 'validate' => [
+ 'required_if:generate_favicons,true',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_ios_color',
+ 'field' => [
+ 'theme' => 'nano',
+ 'lock_opacity' => true,
+ 'default_color_mode' => 'HEXA',
+ 'color_modes' => [
+ 'hex',
+ ],
+ 'display' => 'iOS (apple-touch-icon)',
+ 'type' => 'color',
+ 'icon' => 'color',
+ 'listable' => 'hidden',
+ 'instructions' => 'The background color of your favicon on iOS.',
+ 'width' => 50,
+ 'validate' => [
+ 'required_if:generate_favicons,true',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_android_chrome_color',
+ 'field' => [
+ 'theme' => 'nano',
+ 'lock_opacity' => true,
+ 'default_color_mode' => 'HEXA',
+ 'color_modes' => [
+ 'hex',
+ ],
+ 'display' => 'Android Chrome',
+ 'type' => 'color',
+ 'icon' => 'color',
+ 'listable' => 'hidden',
+ 'instructions' => 'The background color of your favicon on Android Chrome.',
+ 'width' => 50,
+ 'validate' => [
+ 'required_if:generate_favicons,true',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_favicon_overrides',
+ 'field' => [
+ 'type' => 'section',
+ 'listable' => 'hidden',
+ 'display' => 'Favicon Overrides',
+ 'instructions' => 'You may override the automatically generated favicons with your own.',
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_safari_override',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => false,
+ 'listable' => 'hidden',
+ 'folder' => 'favicons',
+ 'display' => 'Safari (mask-icon)',
+ 'instructions' => 'A single color and as flattened as possible SVG. This will use the `Safari` color defined above.',
+ 'width' => 50,
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ 'validate' => [
+ 'image',
+ 'mimes:svg',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_ios_override',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => false,
+ 'listable' => 'hidden',
+ 'folder' => 'favicons',
+ 'display' => 'iOS (apple-touch-icon)',
+ 'instructions' => 'A `180x180px` PNG for iOS devices.',
+ 'width' => 50,
+ 'validate' => [
+ 'image',
+ 'mimes:png',
+ 'dimensions:width=180,height=180',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'favicon_android_chrome_override',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => false,
+ 'listable' => 'hidden',
+ 'folder' => 'favicons',
+ 'display' => 'Android Chrome',
+ 'instructions' => 'A `512x512px` PNG for Android devices.',
+ 'width' => 50,
+ 'validate' => [
+ 'image',
+ 'mimes:png',
+ 'dimensions:width=512,height=512',
+ ],
+ 'if' => [
+ 'generate_favicons' => 'equals true',
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/Sections/GeneralSection.php b/src/Blueprints/Sections/GeneralSection.php
new file mode 100644
index 00000000..98f1822f
--- /dev/null
+++ b/src/Blueprints/Sections/GeneralSection.php
@@ -0,0 +1,207 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'General',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ $fields->push($this->generalSection());
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function generalSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_titles',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure how your site titles appear.',
+ 'display' => 'Titles',
+ ],
+ ],
+ [
+ 'handle' => 'site_name',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Website Name',
+ 'instructions' => 'Set the name of the website.',
+ 'width' => 50,
+ ],
+ ],
+ [
+ 'handle' => 'title_separator',
+ 'field' => [
+ 'options' => [
+ ' | ' => '|',
+ ' - ' => '-',
+ ' / ' => '/',
+ ' :: ' => '::',
+ ' > ' => '>',
+ ' ~ ' => '~',
+ ],
+ 'clearable' => false,
+ 'multiple' => false,
+ 'searchable' => true,
+ 'localizable' => true,
+ 'taggable' => false,
+ 'push_tags' => false,
+ 'cast_booleans' => false,
+ 'type' => 'select',
+ 'instructions' => 'Set the separator of the page title and site name.',
+ 'width' => 50,
+ 'listable' => 'hidden',
+ 'display' => 'Separator',
+ 'default' => '|',
+ ],
+ ],
+ [
+ 'handle' => 'section_schema',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Add basic [JSON-LD](https://developers.google.com/search/docs/guides/intro-structured-data) information about this website.',
+ 'display' => 'Knowledge Graph',
+ ],
+ ],
+ [
+ 'handle' => 'json_ld_type',
+ 'field' => [
+ 'options' => [
+ 'none' => 'None',
+ 'organization' => 'Organization',
+ 'person' => 'Person',
+ 'custom' => 'Custom',
+ ],
+ 'default' => 'none',
+ 'localizable' => true,
+ 'type' => 'button_group',
+ 'instructions' => 'The type of content this website represents.',
+ 'listable' => false,
+ 'display' => 'Type',
+ 'width' => 50,
+ ],
+ ],
+ [
+ 'handle' => 'organization_name',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Organization Name',
+ 'instructions' => 'Set the name of the organization.',
+ 'width' => 50,
+ 'if' => [
+ 'json_ld_type' => 'equals organization',
+ ],
+ 'validate' => [
+ 'required_if:json_ld_type,organization',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'organization_logo',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => false,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Organization Logo',
+ 'instructions' => 'Add an optional logo with a minimum size of `112x112px`.',
+ 'validate' => [
+ 'image',
+ ],
+ 'if' => [
+ 'json_ld_type' => 'equals organization',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'person_name',
+ 'field' => [
+ 'listable' => 'hidden',
+ 'display' => 'Person Name',
+ 'instructions' => 'Set the name of the person.',
+ 'width' => 50,
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'if' => [
+ 'json_ld_type' => 'equals person',
+ ],
+ 'validate' => [
+ 'required_if:json_ld_type,person',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'json_ld',
+ 'field' => [
+ 'theme' => 'material',
+ 'mode' => 'javascript',
+ 'indent_type' => 'tabs',
+ 'indent_size' => 4,
+ 'key_map' => 'default',
+ 'line_numbers' => true,
+ 'line_wrapping' => true,
+ 'display' => 'JSON-LD Schema',
+ 'instructions' => 'Add custom [JSON-LD](https://developers.google.com/search/docs/guides/intro-structured-data) you want to include on each entry. This will be wrapped in the appropriate script tag.',
+ 'type' => 'code',
+ 'icon' => 'code',
+ 'listable' => 'hidden',
+ 'if' => [
+ 'json_ld_type' => 'equals custom',
+ ],
+ 'validate' => [
+ 'required_if:json_ld_type,custom',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_breadcrumbs',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Add [breadcrumbs](https://developers.google.com/search/docs/data-types/breadcrumb) to your entries.',
+ 'display' => 'Breadcrumbs',
+ ],
+ ],
+ [
+ 'handle' => 'breadcrumbs',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Add breadcrumbs',
+ 'listable' => false,
+ 'localizable' => true,
+ 'display' => 'Breadcrumbs',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/Sections/SeoEntrySection.php b/src/Blueprints/Sections/SeoEntrySection.php
new file mode 100644
index 00000000..4bcc50fe
--- /dev/null
+++ b/src/Blueprints/Sections/SeoEntrySection.php
@@ -0,0 +1,369 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'SEO',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ $fields->push($this->seoSection());
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function seoSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_seo_tags',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the basic SEO Tags of this entry.',
+ 'display' => 'SEO Tags',
+ ],
+ ],
+ [
+ 'handle' => 'seo_title',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Meta Title',
+ 'character_limit' => 60,
+ 'instructions' => 'Set the Meta Title of this entry. Defaults to the entry\'s `Title`.',
+ 'antlers' => false,
+ 'validate' => [
+ 'max:60',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'seo_description',
+ 'field' => [
+ 'type' => 'textarea',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Meta Description',
+ 'character_limit' => 160,
+ 'instructions' => 'Set the Meta Description of this entry.',
+ 'validate' => [
+ 'max:160',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_og',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the Open Graph settings of this entry.',
+ 'display' => 'Open Graph',
+ ],
+ ],
+ [
+ 'handle' => 'og_title',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Open Graph Title',
+ 'instructions' => 'Set the Open Graph Title of this entry. Defaults to the entry\'s `Meta Title` or `Title`.',
+ 'character_limit' => 70,
+ 'antlers' => false,
+ 'validate' => [
+ 'max:70',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'og_description',
+ 'field' => [
+ 'type' => 'textarea',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Open Graph Description',
+ 'character_limit' => '200',
+ 'instructions' => 'Set the Open Graph Description of this entry. Defaults to the entry\'s `Meta Description`.',
+ 'width' => 100,
+ 'validate' => [
+ 'max:200',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'og_image',
+ 'field' => [
+ 'type' => 'assets',
+ 'mode' => 'list',
+ 'max_files' => 1,
+ 'allow_uploads' => true,
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Open Graph Image',
+ 'folder' => 'social_images',
+ 'instructions' => 'Add an Open Graph Image for this entry. The recommended size is `1200x630px`.',
+ 'validate' => [
+ 'image',
+ 'mimes:jpg,png',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_twitter',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the Twitter settings of this entry.',
+ 'display' => 'Twitter',
+ ],
+ ],
+ [
+ 'handle' => 'twitter_title',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Twitter Title',
+ 'instructions' => 'Set the Twitter Title of this entry. Defaults to the entry\'s `Meta Title` or `Title`.',
+ 'character_limit' => 70,
+ 'antlers' => false,
+ 'validate' => [
+ 'max:70',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'twitter_description',
+ 'field' => [
+ 'type' => 'textarea',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Twitter Description',
+ 'character_limit' => '200',
+ 'instructions' => 'Set the Twitter Description of this entry. Defaults to the entry\'s `Meta Description`.',
+ 'width' => 100,
+ 'validate' => [
+ 'max:200',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'twitter_image',
+ 'field' => [
+ 'type' => 'assets',
+ 'mode' => 'list',
+ 'max_files' => 1,
+ 'allow_uploads' => true,
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Twitter Image',
+ 'folder' => 'social_images',
+ 'instructions' => 'Add a Twitter Image for this entry with an aspect ratio of `2:1` and minimum size of `300x157px`.',
+ 'validate' => [
+ 'image',
+ 'mimes:jpg,png',
+ 'dimensions:min_width=300,min_height=157',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_canonical_url',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the canonical URL settings for this entry.',
+ 'display' => 'Canonical URL',
+ ],
+ ],
+ [
+ 'handle' => 'seo_canonical_type',
+ 'field' => [
+ 'options' => [
+ 'entry' => 'Current Entry',
+ 'current' => 'Current Domain',
+ 'external' => 'External Domain',
+ ],
+ 'display' => 'Canonical URL',
+ 'type' => 'button_group',
+ 'default' => 'entry',
+ 'icon' => 'button_group',
+ 'instructions' => 'Where should the canonical URL for this entry point to.',
+ 'listable' => 'hidden',
+ ],
+ ],
+ [
+ 'handle' => 'seo_canonical_current',
+ 'field' => [
+ 'type' => 'entries',
+ 'max_items' => 1,
+ 'mode' => 'select',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'display' => 'Canonical URL',
+ 'instructions' => 'If this is an entry with duplicate content, link to the entry with the original content.',
+ 'validate' => [
+ 'required_if:seo_canonical_type,current',
+ ],
+ 'if' => [
+ 'seo_canonical_type' => 'equals current',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'seo_canonical_external',
+ 'field' => [
+ 'input_type' => 'url',
+ 'display' => 'Canonical URL',
+ 'type' => 'text',
+ 'icon' => 'text',
+ 'listable' => 'hidden',
+ 'validate' => [
+ 'required_if:seo_canonical_type,external',
+ ],
+ 'if' => [
+ 'seo_canonical_type' => 'equals external',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'section_indexing',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the indexing settings for this entry.',
+ 'display' => 'Indexing',
+ ],
+ ],
+ [
+ 'handle' => 'seo_noindex',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Prevent this entry from being indexed by search engines.',
+ 'listable' => 'hidden',
+ 'width' => 50,
+ 'display' => 'Noindex',
+ ],
+ ],
+ [
+ 'handle' => 'seo_nofollow',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Prevent site crawlers from following links in this entry.',
+ 'listable' => 'hidden',
+ 'width' => 50,
+ 'display' => 'Nofollow',
+ ],
+ ],
+ [
+ 'handle' => 'section_sitemap',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the sitemap settings for this entry.',
+ 'display' => 'Sitemap',
+ ],
+ ],
+ [
+ 'handle' => 'sitemap_priority',
+ 'field' => [
+ 'options' => [
+ '0.0' => '0.0',
+ '0.1' => '0.1',
+ '0.2' => '0.2',
+ '0.3' => '0.3',
+ '0.4' => '0.4',
+ '0.5' => '0.5',
+ '0.6' => '0.6',
+ '0.7' => '0.7',
+ '0.8' => '0.8',
+ '0.9' => '0.9',
+ '1.0' => '1.0',
+ ],
+ 'clearable' => false,
+ 'multiple' => false,
+ 'searchable' => true,
+ 'taggable' => false,
+ 'push_tags' => false,
+ 'cast_booleans' => false,
+ 'type' => 'select',
+ 'instructions' => 'Choose the priorty of this entry in the sitemap. `1.0` is the most important.',
+ 'width' => 50,
+ 'default' => '0.5',
+ 'listable' => 'hidden',
+ 'display' => 'Priority',
+ ],
+ ],
+ [
+ 'handle' => 'sitemap_change_frequency',
+ 'field' => [
+ 'options' => [
+ 'always' => 'Always',
+ 'hourly' => 'Hourly',
+ 'daily' => 'Daily',
+ 'weekly' => 'Weekly',
+ 'monthly' => 'Monthly',
+ 'yearly' => 'Yearly',
+ 'never' => 'Never',
+ ],
+ 'clearable' => false,
+ 'multiple' => false,
+ 'searchable' => true,
+ 'taggable' => false,
+ 'push_tags' => false,
+ 'cast_booleans' => false,
+ 'type' => 'select',
+ 'instructions' => 'Choose the frequency in which search engines should crawl this entry.',
+ 'width' => 50,
+ 'default' => 'weekly',
+ 'listable' => 'hidden',
+ 'display' => 'Change Frequency',
+ ],
+ ],
+ [
+ 'handle' => 'section_json_ld',
+ 'field' => [
+ 'type' => 'section',
+ 'display' => 'JSON-ld Schema',
+ 'instructions' => 'Configure custom [JSON-LD](https://developers.google.com/search/docs/guides/intro-structured-data) for this entry.',
+ ],
+ ],
+ [
+ 'handle' => 'json_ld',
+ 'field' => [
+ 'theme' => 'material',
+ 'mode' => 'javascript',
+ 'indent_type' => 'tabs',
+ 'indent_size' => 4,
+ 'key_map' => 'default',
+ 'line_numbers' => true,
+ 'line_wrapping' => true,
+ 'display' => 'JSON-LD Schema',
+ 'instructions' => 'Add custom [JSON-LD](https://developers.google.com/search/docs/guides/intro-structured-data) for this entry. This will be wrapped in the appropriate script tag.',
+ 'type' => 'code',
+ 'icon' => 'code',
+ 'listable' => 'hidden',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/Sections/SitemapSection.php b/src/Blueprints/Sections/SitemapSection.php
new file mode 100644
index 00000000..1f3740df
--- /dev/null
+++ b/src/Blueprints/Sections/SitemapSection.php
@@ -0,0 +1,56 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'Sitemap',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ $fields->push($this->sitemapSection());
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function sitemapSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_sitemap',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure your `sitemap.xml`',
+ 'display' => 'Sitemap',
+ ],
+ ],
+ [
+ 'handle' => 'sitemap_collections',
+ 'field' => [
+ 'mode' => 'select',
+ 'type' => 'collections',
+ 'instructions' => 'Select the collections you want to include in your sitemap.',
+ 'listable' => 'hidden',
+ 'display' => 'Collections',
+ 'width' => 50,
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/Sections/SocialSection.php b/src/Blueprints/Sections/SocialSection.php
new file mode 100644
index 00000000..af281660
--- /dev/null
+++ b/src/Blueprints/Sections/SocialSection.php
@@ -0,0 +1,165 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'Social',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ if (config('advanced-seo.social_images.generator', false)) {
+ $fields->push($this->socialImagesGeneratorSection());
+ }
+
+ if (config('advanced-seo.social_images.open_graph', true)) {
+ $fields->push($this->openGraphSection());
+ }
+
+ if (config('advanced-seo.social_images.twitter', true)) {
+ $fields->push($this->twitterSection());
+ }
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function socialImagesGeneratorSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_social_images',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Automatically generate your social images.',
+ 'display' => 'Generate Social Images',
+ ],
+ ],
+ [
+ 'handle' => 'generate_social_images',
+ 'field' => [
+ 'display' => 'Generate Social Images',
+ 'type' => 'toggle',
+ 'icon' => 'toggle',
+ 'instructions' => 'Activate to generate your social images.',
+ 'listable' => 'hidden',
+ ],
+ ],
+ [
+ 'handle' => 'social_images_collections',
+ 'field' => [
+ 'mode' => 'select',
+ 'display' => 'Collections',
+ 'type' => 'collections',
+ 'icon' => 'collections',
+ 'instructions' => 'Select the collections you want to generate images for.',
+ 'listable' => 'hidden',
+ 'width' => 50,
+ 'if' => [
+ 'generate_social_images' => 'equals true',
+ ],
+ 'validate' => [
+ 'required_if:generate_social_images,true',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function openGraphSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_og',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure the default Open Graph settings.',
+ 'display' => 'Open Graph',
+ ],
+ ],
+ [
+ 'handle' => 'og_image',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'folder' => 'social_images',
+ 'display' => 'Open Graph Image',
+ 'instructions' => 'Add a default Open Graph Image. The recommended size is `1200x630px`.',
+ 'validate' => [
+ 'image',
+ 'mimes:jpg,png',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function twitterSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_twitter',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Configure your default Twitter settings.',
+ 'display' => 'Twitter',
+ ],
+ ],
+ [
+ 'handle' => 'twitter_handle',
+ 'field' => [
+ 'listable' => 'hidden',
+ 'display' => 'Twitter Username',
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'instructions' => 'Add your Twitter username.',
+ 'prepend' => '@',
+ 'antlers' => false,
+ 'width' => 50,
+ ],
+ ],
+ [
+ 'handle' => 'twitter_image',
+ 'field' => [
+ 'mode' => 'list',
+ 'container' => 'seo',
+ 'restrict' => true,
+ 'allow_uploads' => true,
+ 'max_files' => 1,
+ 'type' => 'assets',
+ 'localizable' => true,
+ 'listable' => 'hidden',
+ 'folder' => 'social_images',
+ 'display' => 'Twitter Image',
+ 'instructions' => 'Add a default Twitter Image with an aspect ratio of `2:1` and minimum size of `300x157px`.',
+ 'validate' => [
+ 'image',
+ 'mimes:jpg,png',
+ 'dimensions:min_width=300,min_height=157',
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/Sections/TrackersSection.php b/src/Blueprints/Sections/TrackersSection.php
new file mode 100644
index 00000000..71d810f5
--- /dev/null
+++ b/src/Blueprints/Sections/TrackersSection.php
@@ -0,0 +1,237 @@
+fields();
+
+ if (empty($fields)) {
+ return [];
+ }
+
+ return [
+ 'display' => 'Trackers',
+ 'fields' => $this->fields(),
+ ];
+ }
+
+ public function fields(): array
+ {
+ $fields = collect();
+
+ if (config('advanced-seo.trackers.site_verification', true)) {
+ $fields->push($this->siteVerificationSection());
+ }
+
+ if (config('advanced-seo.trackers.fathom', true)) {
+ $fields->push($this->fathomSection());
+ }
+
+ if (config('advanced-seo.trackers.cloudflare', true)) {
+ $fields->push($this->cloudflareSection());
+ }
+
+ if (config('advanced-seo.trackers.google_tag_manager', true)) {
+ $fields->push($this->googleTagManagerSection());
+ }
+
+ return $fields->flatten(1)->toArray();
+ }
+
+ protected function siteVerificationSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_verification',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Verify your site ownership.',
+ 'display' => 'Site verifications',
+ ],
+ ],
+ [
+ 'handle' => 'use_google_site_verification',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Add the `google-site-verification` meta tag to your head.',
+ 'listable' => false,
+ 'display' => 'Google Site Verification',
+ ],
+ ],
+ [
+ 'handle' => 'site_verification_code',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'listable' => 'hidden',
+ 'width' => 50,
+ 'display' => 'Verification Code',
+ 'instructions' => 'Add your site verification code.',
+ 'validate' => [
+ 'required_if:use_google_site_verification,true',
+ ],
+ 'if' => [
+ 'use_google_site_verification' => 'equals true',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function fathomSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_fathom',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Use [Fathom](https://usefathom.com) as a privacy-friendly alternative to Google Analytics.',
+ 'display' => 'Fathom',
+ ],
+ ],
+ [
+ 'handle' => 'use_fathom',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Add the Fathom tracking script to your head.',
+ 'listable' => false,
+ 'display' => 'Fathom',
+ ],
+ ],
+ [
+ 'handle' => 'fathom_id',
+ 'field' => [
+ 'width' => 50,
+ 'display' => 'Site ID',
+ 'instructions' => 'Add your Site ID.',
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'listable' => 'hidden',
+ 'antlers' => true,
+ 'validate' => [
+ 'required_if:use_fathom,true',
+ ],
+ 'if' => [
+ 'use_fathom' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'fathom_domain',
+ 'field' => [
+ 'width' => 50,
+ 'display' => 'Custom Domain',
+ 'instructions' => 'Add an optional Custom Domain.',
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'listable' => 'hidden',
+ 'antlers' => true,
+ 'if' => [
+ 'use_fathom' => 'equals true',
+ ],
+ ],
+ ],
+ [
+ 'handle' => 'fathom_spa',
+ 'field' => [
+ 'display' => 'SPA Mode',
+ 'type' => 'toggle',
+ 'icon' => 'toggle',
+ 'instructions' => 'Activate if your site is a single page application.',
+ 'listable' => 'hidden',
+ 'validate' => [
+ 'required_if:use_fathom,true',
+ ],
+ 'if' => [
+ 'use_fathom' => 'equals true',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function cloudflareSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_cloudflare_web_analytics',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Use [Cloudflare Web Analytics](https://www.cloudflare.com/web-analytics) as a privacy-friendly alternative to Google Analytics.',
+ 'display' => 'Cloudflare Web Analytics',
+ ],
+ ],
+ [
+ 'handle' => 'use_cloudflare_web_analytics',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Add the Cloudflare tracking script to your head.',
+ 'listable' => false,
+ 'display' => 'Cloudflare Web Analytics',
+ ],
+ ],
+ [
+ 'handle' => 'cloudflare_web_analytics',
+ 'field' => [
+ 'width' => 50,
+ 'display' => 'Beacon Token',
+ 'instructions' => 'Add your Beacon Token.',
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'listable' => 'hidden',
+ 'validate' => [
+ 'required_if:use_cloudflare_web_analytics,true',
+ ],
+ 'if' => [
+ 'use_cloudflare_web_analytics' => 'equals true',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function googleTagManagerSection(): array
+ {
+ return [
+ [
+ 'handle' => 'section_google_tag_manager',
+ 'field' => [
+ 'type' => 'section',
+ 'instructions' => 'Use [Google Tag Manager](https://marketingplatform.google.com/about/tag-manager) to track your users. You are `required by privacy law` to get your user\'s consent before loading any tracking scripts. You also need to inform them about what data you collect and what you intent to do with it.',
+ 'display' => 'Google Tag Manager',
+ ],
+ ],
+ [
+ 'handle' => 'use_google_tag_manager',
+ 'field' => [
+ 'type' => 'toggle',
+ 'instructions' => 'Add the Google Tag Manager tracking scripts.',
+ 'listable' => false,
+ 'display' => 'Google Tag Manager',
+ ],
+ ],
+ [
+ 'handle' => 'google_tag_manager',
+ 'field' => [
+ 'input_type' => 'text',
+ 'type' => 'text',
+ 'listable' => 'hidden',
+ 'width' => 50,
+ 'display' => 'Container ID',
+ 'instructions' => 'Add your Container ID.',
+ 'validate' => [
+ 'required_if:use_google_tag_manager,true',
+ ],
+ 'if' => [
+ 'use_google_tag_manager' => 'equals true',
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Blueprints/SeoGlobalsBlueprint.php b/src/Blueprints/SeoGlobalsBlueprint.php
new file mode 100644
index 00000000..11455929
--- /dev/null
+++ b/src/Blueprints/SeoGlobalsBlueprint.php
@@ -0,0 +1,31 @@
+ $this->sections(),
+ ];
+ }
+
+ public function sections(): array
+ {
+ return array_filter([
+ 'general' => GeneralSection::contents(),
+ 'favicons' => FaviconsSection::contents(),
+ 'social' => SocialSection::contents(),
+ 'trackers' => TrackersSection::contents(),
+ 'sitemap' => SitemapSection::contents(),
+ ]);
+ }
+}
diff --git a/src/Commands/SetupSeo.php b/src/Commands/SetupSeo.php
new file mode 100644
index 00000000..aebac71b
--- /dev/null
+++ b/src/Commands/SetupSeo.php
@@ -0,0 +1,66 @@
+setupGlobals();
+ $this->setupAssetContainers();
+
+ $this->info("Advanced SEO is configured and ready to go!");
+ }
+
+ protected function setupGlobals(): void
+ {
+ $this->makeGlobal(SeoGlobals::handle(), SeoGlobals::title());
+ }
+
+ protected function setupAssetContainers(): void
+ {
+ $this->makeAssetContainer('seo', 'SEO');
+ }
+
+ protected function makeGlobal(string $handle, string $title): void
+ {
+ if (! GlobalSet::findByHandle($handle)) {
+ $global = GlobalSet::make($handle)->title($title);
+
+ $global->addLocalization($global->makeLocalization(Site::default()->handle()));
+
+ $global->save();
+
+ $this->line("[✓] Created SEO global in content/globals/$handle.yaml");
+ }
+ }
+
+ protected function makeAssetContainer(string $handle, string $title): void
+ {
+ if (! AssetContainer::findByHandle($handle)) {
+ AssetContainer::make($handle)
+ ->title($title)
+ ->disk('assets')
+ ->allowUploads(true)
+ ->allowDownloading(true)
+ ->allowMoving(true)
+ ->allowRenaming(true)
+ ->createFolders(true)
+ ->save();
+
+ $this->line("[✓] Created $title assets container in content/assets/$handle.yaml");
+ }
+ }
+}
diff --git a/src/Contracts/Blueprint.php b/src/Contracts/Blueprint.php
new file mode 100644
index 00000000..1a3f5f7c
--- /dev/null
+++ b/src/Contracts/Blueprint.php
@@ -0,0 +1,8 @@
+handle;
+ }
+
+ public function title(): string
+ {
+ return $this->title;
+ }
+
+ public function blueprint(): array
+ {
+ return SeoGlobalsBlueprint::contents();
+ }
+}
diff --git a/src/Listeners/AppendEntryBlueprint.php b/src/Listeners/AppendEntryBlueprint.php
new file mode 100644
index 00000000..a6e1fe48
--- /dev/null
+++ b/src/Listeners/AppendEntryBlueprint.php
@@ -0,0 +1,17 @@
+blueprint->contents();
+ $blueprint['sections']['seo'] = SeoEntrySection::contents();
+
+ $event->blueprint->setContents($blueprint);
+ }
+}
diff --git a/src/Listeners/AppendSeoGlobalsBlueprint.php b/src/Listeners/AppendSeoGlobalsBlueprint.php
new file mode 100644
index 00000000..c871dac5
--- /dev/null
+++ b/src/Listeners/AppendSeoGlobalsBlueprint.php
@@ -0,0 +1,16 @@
+globals->id() === SeoGlobals::handle()) {
+ $event->blueprint->setContents(SeoGlobals::blueprint());
+ }
+ }
+}
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
new file mode 100644
index 00000000..a00d1589
--- /dev/null
+++ b/src/ServiceProvider.php
@@ -0,0 +1,22 @@
+ [
+ 'Aerni\AdvancedSeo\Listeners\AppendEntryBlueprint',
+ ],
+ 'Statamic\Events\GlobalVariablesBlueprintFound' => [
+ 'Aerni\AdvancedSeo\Listeners\AppendSeoGlobalsBlueprint',
+ ]
+ ];
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 00000000..e8b655de
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ mode: 'jit',
+ prefix: 'as-',
+ purge: [
+ './resources/**/*.html',
+ './resources/**/*.php',
+ './resources/**/*.js',
+ './resources/**/*.vue',
+ './config/classify.php'
+ ]
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 00000000..928575d6
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,79 @@
+ Statamic::class,
+ ];
+ }
+
+ /**
+ * Load Environment
+ *
+ * @param \Illuminate\Foundation\Application $app
+ * @return void
+ */
+ protected function getEnvironmentSetUp($app): void
+ {
+ parent::getEnvironmentSetUp($app);
+
+ $app->make(Manifest::class)->manifest = [
+ 'aerni/advanced-seo' => [
+ 'id' => 'aerni/advanced-seo',
+ 'namespace' => 'Aerni\\AdvancedSeo\\',
+ ],
+ ];
+ }
+
+ /**
+ * Resolve the application configuration and set the Statamic configuration
+ *
+ * @param \Illuminate\Foundation\Application $app
+ * @return void
+ */
+ protected function resolveApplicationConfiguration($app): void
+ {
+ parent::resolveApplicationConfiguration($app);
+
+ $configs = [
+ 'assets', 'cp', 'forms', 'routes', 'sites',
+ 'stache', 'static_caching', 'system', 'users',
+ ];
+
+ foreach ($configs as $config) {
+ $app['config']->set("statamic.$config", require(__DIR__ . "/../vendor/statamic/cms/config/{$config}.php"));
+ }
+
+ $app['config']->set('advanced-seo', require(__DIR__.'/../config/advanced-seo.php'));
+ }
+}
diff --git a/webpack.mix.js b/webpack.mix.js
new file mode 100644
index 00000000..5d8427de
--- /dev/null
+++ b/webpack.mix.js
@@ -0,0 +1,11 @@
+const mix = require('laravel-mix');
+
+mix.setPublicPath('resources/dist')
+ .js('resources/js/advanced-seo.js', 'js').vue()
+ .postCss('resources/css/advanced-seo.css', 'css', [
+ require('tailwindcss')
+ ])
+
+if (mix.inProduction()) {
+ mix.version();
+}