├── templates
├── layout.html.twig
└── preview
│ ├── ogGraph
│ ├── default.html.twig
│ └── facebook.html.twig
│ ├── twitterCard
│ └── preview.html.twig
│ └── titleDescription
│ └── preview.html.twig
├── config
├── install
│ ├── translations
│ │ └── admin.csv
│ └── sql
│ │ └── install.sql
├── services.yaml
├── services
│ ├── controller.yaml
│ ├── twig.yaml
│ ├── command.yaml
│ ├── install.yaml
│ ├── queue.yaml
│ ├── meta_data.yaml
│ ├── third_party
│ │ ├── pimcore_xliff.yaml
│ │ ├── pimcore_seo.yaml
│ │ ├── news.yaml
│ │ └── coreshop.yaml
│ ├── middleware.yaml
│ ├── maintenance.yaml
│ ├── resource_processor.yaml
│ ├── tools.yaml
│ ├── repository.yaml
│ ├── meta_data_extractor.yaml
│ ├── worker.yaml
│ ├── logger.yaml
│ ├── event_listener.yaml
│ └── meta_data_integrator.yaml
├── pimcore
│ ├── config.yaml
│ └── routing.yaml
└── doctrine
│ └── model
│ ├── ElementMetaData.orm.yml
│ └── QueueEntry.orm.yml
├── public
├── img
│ ├── integrator
│ │ ├── demoImage.jpg
│ │ ├── icon_title_description.svg
│ │ ├── icon_og.svg
│ │ ├── icon_html_tags.svg
│ │ ├── star.svg
│ │ ├── icon_twitter.svg
│ │ └── icon_schema.svg
│ └── seo_icon_white.svg
├── js
│ ├── metaData
│ │ ├── objectMetaDataPanel.js
│ │ ├── integrator
│ │ │ ├── twitterCardIntegrator.js
│ │ │ └── ogIntegrator.js
│ │ ├── components
│ │ │ └── seoHrefTextField.js
│ │ ├── documentMetaDataPanel.js
│ │ └── extension
│ │ │ └── integratorValueFetcher.js
│ └── plugin.js
└── css
│ ├── admin.css
│ └── integrator
│ ├── default.css
│ └── title-description.css
├── src
├── Exception
│ └── WorkerResponseInterceptException.php
├── Middleware
│ ├── MiddlewareInterface.php
│ ├── Middleware.php
│ ├── MiddlewareDispatcherInterface.php
│ ├── MiddlewareAdapterInterface.php
│ └── MiddlewareDispatcher.php
├── Queue
│ ├── QueueDataProcessorInterface.php
│ └── QueueDataProcessor.php
├── Logger
│ ├── LoggerInterface.php
│ └── Logger.php
├── MetaData
│ ├── MetaDataProviderInterface.php
│ ├── Extractor
│ │ ├── ExtractorInterface.php
│ │ ├── ThirdParty
│ │ │ ├── News
│ │ │ │ └── EntryMetaExtractor.php
│ │ │ └── CoreShop
│ │ │ │ ├── TitleDescriptionExtractor.php
│ │ │ │ └── OGExtractor.php
│ │ └── IntegratorExtractor.php
│ ├── Integrator
│ │ ├── XliffAwareIntegratorInterface.php
│ │ ├── IntegratorInterface.php
│ │ ├── AbstractIntegrator.php
│ │ ├── HtmlTagIntegrator.php
│ │ └── TitleDescriptionIntegrator.php
│ └── MetaDataProvider.php
├── Tool
│ ├── LocaleProviderInterface.php
│ ├── UrlGeneratorInterface.php
│ ├── LocaleProvider.php
│ ├── Install.php
│ └── UrlGenerator.php
├── Manager
│ ├── QueueManagerInterface.php
│ └── ElementMetaDataManagerInterface.php
├── Worker
│ ├── WorkerResponseInterface.php
│ ├── IndexWorkerInterface.php
│ └── WorkerResponse.php
├── EventListener
│ ├── Maintenance
│ │ └── QueuedIndexDataTask.php
│ ├── ElementMetaDataListener.php
│ ├── Admin
│ │ ├── AssetListener.php
│ │ ├── XliffListener.php
│ │ └── SeoDocumentEditorListener.php
│ ├── AutoMetaDataAttachListener.php
│ └── PimcoreElementListener.php
├── Registry
│ ├── IndexWorkerRegistryInterface.php
│ ├── MetaDataExtractorRegistryInterface.php
│ ├── MetaDataIntegratorRegistryInterface.php
│ ├── ResourceProcessorRegistryInterface.php
│ ├── IndexWorkerRegistry.php
│ ├── MetaDataExtractorRegistry.php
│ ├── MetaDataIntegratorRegistry.php
│ └── ResourceProcessorRegistry.php
├── Repository
│ ├── QueueEntryRepositoryInterface.php
│ ├── ElementMetaDataRepositoryInterface.php
│ ├── QueueEntryRepository.php
│ └── ElementMetaDataRepository.php
├── Migrations
│ ├── Version20230809105540.php
│ ├── Version20240827080929.php
│ └── Version20240809095425.php
├── DependencyInjection
│ ├── Compiler
│ │ ├── ThirdParty
│ │ │ ├── RemoveNewsMetaDataListenerPass.php
│ │ │ └── RemoveCoreShopExtractorListenerPass.php
│ │ ├── MetaMiddlewareAdapterPass.php
│ │ ├── MetaDataExtractorPass.php
│ │ ├── ResourceProcessorPass.php
│ │ ├── IndexWorkerPass.php
│ │ └── MetaDataIntegratorPass.php
│ └── SeoExtension.php
├── Model
│ ├── ElementMetaDataInterface.php
│ ├── QueueEntryInterface.php
│ ├── ElementMetaData.php
│ ├── SeoMetaDataInterface.php
│ ├── QueueEntry.php
│ └── SeoMetaData.php
├── Command
│ └── QueuedIndexDataCommand.php
├── ResourceProcessor
│ └── ResourceProcessorInterface.php
├── Twig
│ └── Extension
│ │ └── SeoExtension.php
├── SeoBundle.php
├── Controller
│ └── Admin
│ │ └── MetaDataController.php
└── Helper
│ └── ArrayHelper.php
├── LICENSE.md
├── composer.json
├── UPGRADE.md
├── translations
├── admin.en.yml
└── admin.de.yml
└── README.md
/templates/layout.html.twig:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/install/translations/admin.csv:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/services.yaml:
--------------------------------------------------------------------------------
1 | imports:
2 | - { resource: services/*.yaml }
--------------------------------------------------------------------------------
/public/img/integrator/demoImage.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dachcom-digital/pimcore-seo/HEAD/public/img/integrator/demoImage.jpg
--------------------------------------------------------------------------------
/config/services/controller.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | SeoBundle\Controller\:
4 | resource: '../../src/Controller'
5 | public: true
6 | autowire: true
7 | autoconfigure: true
8 | tags: ['controller.service_arguments']
9 |
--------------------------------------------------------------------------------
/config/services/twig.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Twig\Extension\SeoExtension:
9 | tags:
10 | - { name: twig.extension }
--------------------------------------------------------------------------------
/config/services/command.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Command\QueuedIndexDataCommand:
9 | tags:
10 | - { name: console.command }
--------------------------------------------------------------------------------
/config/services/install.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: true
7 |
8 | SeoBundle\Tool\Install:
9 | arguments:
10 | $bundle: "@=service('kernel').getBundle('SeoBundle')"
11 |
--------------------------------------------------------------------------------
/config/services/queue.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Queue\QueueDataProcessorInterface: '@SeoBundle\Queue\QueueDataProcessor'
9 | SeoBundle\Queue\QueueDataProcessor: ~
10 |
--------------------------------------------------------------------------------
/config/services/meta_data.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\MetaData\MetaDataProviderInterface: '@SeoBundle\MetaData\MetaDataProvider'
9 | SeoBundle\MetaData\MetaDataProvider: ~
10 |
--------------------------------------------------------------------------------
/config/services/third_party/pimcore_xliff.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\EventListener\Admin\XliffListener:
9 | tags:
10 | - { name: kernel.event_subscriber }
11 |
--------------------------------------------------------------------------------
/config/services/third_party/pimcore_seo.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\EventListener\Admin\SeoDocumentEditorListener:
9 | tags:
10 | - { name: kernel.event_subscriber }
11 |
--------------------------------------------------------------------------------
/config/services/middleware.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Middleware\MiddlewareDispatcherInterface: '@SeoBundle\Middleware\MiddlewareDispatcher'
9 | SeoBundle\Middleware\MiddlewareDispatcher: ~
10 |
--------------------------------------------------------------------------------
/config/pimcore/config.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | orm:
3 | auto_generate_proxy_classes: '%kernel.debug%'
4 | entity_managers:
5 | default:
6 | auto_mapping: true
7 |
8 | doctrine_migrations:
9 | migrations_paths:
10 | 'SeoBundle\Migrations': '@SeoBundle/src/Migrations'
--------------------------------------------------------------------------------
/config/services/maintenance.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\EventListener\Maintenance\QueuedIndexDataTask:
9 | tags:
10 | - { name: pimcore.maintenance.task, type: seo_check_queued_index_data }
--------------------------------------------------------------------------------
/config/services/resource_processor.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Registry\ResourceProcessorRegistryInterface: '@SeoBundle\Registry\ResourceProcessorRegistry'
9 | SeoBundle\Registry\ResourceProcessorRegistry: ~
10 |
--------------------------------------------------------------------------------
/config/services/third_party/news.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\MetaData\Extractor\ThirdParty\News\EntryMetaExtractor:
9 | tags:
10 | - { name: seo.meta_data.extractor, identifier: news_entry_meta, priority: 10 }
11 |
--------------------------------------------------------------------------------
/public/img/integrator/icon_title_description.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/config/services/tools.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Tool\UrlGeneratorInterface: '@SeoBundle\Tool\UrlGenerator'
9 | SeoBundle\Tool\UrlGenerator: ~
10 |
11 | SeoBundle\Tool\LocaleProviderInterface: '@SeoBundle\Tool\LocaleProvider'
12 | SeoBundle\Tool\LocaleProvider: ~
--------------------------------------------------------------------------------
/public/img/integrator/icon_og.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/img/integrator/icon_html_tags.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/config/services/repository.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Repository\QueueEntryRepositoryInterface: '@SeoBundle\Repository\QueueEntryRepository'
9 | SeoBundle\Repository\QueueEntryRepository: ~
10 |
11 | SeoBundle\Repository\ElementMetaDataRepositoryInterface: '@SeoBundle\Repository\ElementMetaDataRepository'
12 | SeoBundle\Repository\ElementMetaDataRepository: ~
13 |
14 |
--------------------------------------------------------------------------------
/templates/preview/ogGraph/default.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Preview
7 |
8 |
9 |
10 |
11 |
12 |
13 |
No Template selected.
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/config/services/third_party/coreshop.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\MetaData\Extractor\ThirdParty\CoreShop\TitleDescriptionExtractor:
9 | tags:
10 | - { name: seo.meta_data.extractor, identifier: coreshop_title_description, priority: 10 }
11 |
12 | SeoBundle\MetaData\Extractor\ThirdParty\CoreShop\OGExtractor:
13 | tags:
14 | - { name: seo.meta_data.extractor, identifier: coreshop_og_tags, priority: 10 }
--------------------------------------------------------------------------------
/config/services/meta_data_extractor.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Registry\MetaDataExtractorRegistryInterface: '@SeoBundle\Registry\MetaDataExtractorRegistry'
9 | SeoBundle\Registry\MetaDataExtractorRegistry: ~
10 |
11 | SeoBundle\MetaData\Extractor\IntegratorExtractor:
12 | arguments:
13 | $integratorConfiguration: '%seo.meta_data_integrator.configuration%'
14 | tags:
15 | - {name: seo.meta_data.extractor, identifier: integrator, priority: -255 }
--------------------------------------------------------------------------------
/src/Exception/WorkerResponseInterceptException.php:
--------------------------------------------------------------------------------
1 | logger->log($level, $message, $context);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/public/img/integrator/star.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
--------------------------------------------------------------------------------
/src/MetaData/Integrator/XliffAwareIntegratorInterface.php:
--------------------------------------------------------------------------------
1 | dataProcessor->process([]);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Registry/IndexWorkerRegistryInterface.php:
--------------------------------------------------------------------------------
1 |
29 | */
30 | public function getAll(): array;
31 | }
32 |
--------------------------------------------------------------------------------
/public/img/integrator/icon_twitter.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/Registry/MetaDataExtractorRegistryInterface.php:
--------------------------------------------------------------------------------
1 |
29 | */
30 | public function getAll(): array;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Registry/MetaDataIntegratorRegistryInterface.php:
--------------------------------------------------------------------------------
1 |
29 | */
30 | public function getAll(): array;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Registry/ResourceProcessorRegistryInterface.php:
--------------------------------------------------------------------------------
1 |
29 | */
30 | public function getAll(): array;
31 | }
32 |
--------------------------------------------------------------------------------
/templates/preview/twitterCard/preview.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Preview
7 |
8 |
9 |
10 |
11 |
12 |
13 |
23 |
24 |
--------------------------------------------------------------------------------
/config/install/sql/install.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS `seo_element_meta_data` (
2 | `id` int(11) NOT NULL AUTO_INCREMENT,
3 | `element_type` varchar(255) NOT NULL,
4 | `element_id` int(11) NOT NULL,
5 | `integrator` varchar(255) NOT NULL,
6 | `data` longtext NOT NULL COMMENT '(DC2Type:array)',
7 | `release_type` varchar(255) default 'public' not null,
8 | PRIMARY KEY (`id`),
9 | UNIQUE KEY `element_type_id_integrator` (`element_type`,`element_id`,`integrator`, `release_type`)
10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
11 |
12 | CREATE TABLE IF NOT EXISTS `seo_queue_entry` (
13 | `uuid` binary(16) NOT NULL COMMENT '(DC2Type:uuid)',
14 | `type` varchar(255) NOT NULL,
15 | `data_type` varchar(255) NOT NULL,
16 | `data_id` int(11) NOT NULL,
17 | `data_url` longtext NOT NULL,
18 | `worker` varchar(255) NOT NULL,
19 | `resource_processor` varchar(255) NOT NULL,
20 | `creation_date` datetime NOT NULL,
21 | PRIMARY KEY (`uuid`)
22 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
23 |
24 |
--------------------------------------------------------------------------------
/src/Repository/QueueEntryRepositoryInterface.php:
--------------------------------------------------------------------------------
1 |
22 | */
23 | public function findAll(?array $orderBy = null): array;
24 |
25 | /**
26 | * @return array
27 | */
28 | public function findAllForWorker(string $workerName, ?array $orderBy = null): array;
29 |
30 | public function findAtLeastOneForWorker(string $workerName): ?QueueEntryInterface;
31 | }
32 |
--------------------------------------------------------------------------------
/public/js/metaData/components/seoHrefTextField.js:
--------------------------------------------------------------------------------
1 | Ext.define('Seo.HrefTextField', {
2 |
3 | extend: 'Ext.form.TextField',
4 |
5 | href: null,
6 | hrefLocale: null,
7 | customProperties: {},
8 |
9 | /**
10 | * @param locale
11 | */
12 | setHrefLocale: function (locale) {
13 | this.hrefLocale = locale;
14 | },
15 |
16 | /**
17 | * @returns {string|null}
18 | */
19 | getHrefLocale: function () {
20 | return this.hrefLocale;
21 | },
22 |
23 | /**
24 | * @param href
25 | */
26 | setHrefObject: function (href) {
27 | this.href = href;
28 | this.lastValue = null;
29 | this.setValue(this.href.hasOwnProperty('path') ? this.href.path : null);
30 | },
31 |
32 | /**
33 | * @returns {string|null}
34 | */
35 | getValue: function () {
36 | return this.href;
37 | },
38 |
39 | getSubmitData: function () {
40 | var data = {};
41 | data[this.getName()] = this.href;
42 | return data;
43 | }
44 | });
--------------------------------------------------------------------------------
/config/pimcore/routing.yaml:
--------------------------------------------------------------------------------
1 | # Admin Routes
2 | seo.controller.admin.meta_data.get_meta_definitions:
3 | path: /admin/seo/meta-data/get-meta-definitions
4 | defaults: { _controller: SeoBundle\Controller\Admin\MetaDataController::getMetaDataDefinitionsAction }
5 | seo.controller.admin.meta_data.get_element_meta_data_configuration:
6 | path: /admin/seo/meta-data/get-element-meta-data-configuration
7 | defaults: { _controller: SeoBundle\Controller\Admin\MetaDataController::getElementMetaDataConfigurationAction }
8 | seo.controller.admin.meta_data.set_element_meta_data_configuration:
9 | methods: [POST]
10 | path: /admin/seo/meta-data/set-element-meta-data-configuration
11 | defaults: { _controller: SeoBundle\Controller\Admin\MetaDataController::setElementMetaDataConfigurationAction }
12 | seo.controller.admin.meta_data.generate_meta_data_preview:
13 | methods: [GET]
14 | path: /admin/seo/meta-data/generate-meta-data-preview
15 | defaults: { _controller: SeoBundle\Controller\Admin\MetaDataController::generateMetaDataPreviewAction }
16 |
--------------------------------------------------------------------------------
/src/Middleware/Middleware.php:
--------------------------------------------------------------------------------
1 | identifier = $identifier;
24 | $this->middlewareDispatcher = $middlewareDispatcher;
25 | }
26 |
27 | public function addTask(callable $callback): void
28 | {
29 | $this->middlewareDispatcher->registerTask($callback, $this->identifier);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/config/doctrine/model/ElementMetaData.orm.yml:
--------------------------------------------------------------------------------
1 | SeoBundle\Model\ElementMetaData:
2 | type: entity
3 | table: seo_element_meta_data
4 | fields:
5 | id:
6 | column: id
7 | type: integer
8 | id: true
9 | generator:
10 | strategy: AUTO
11 | elementType:
12 | column: element_type
13 | nullable: false
14 | type: string
15 | elementId:
16 | column: element_id
17 | type: integer
18 | nullable: false
19 | integrator:
20 | column: integrator
21 | nullable: false
22 | type: string
23 | data:
24 | column: data
25 | nullable: false
26 | type: array
27 | releaseType:
28 | column: release_type
29 | nullable: false
30 | type: string
31 | options:
32 | default: 'public'
33 | uniqueConstraints:
34 | element_type_id_integrator:
35 | columns: [element_type, element_id, integrator, release_type]
--------------------------------------------------------------------------------
/src/Migrations/Version20230809105540.php:
--------------------------------------------------------------------------------
1 | addSql('ALTER TABLE seo_queue_entry CHANGE uuid uuid BINARY(16) NOT NULL COMMENT "(DC2Type:uuid)";');
31 | }
32 |
33 | public function down(Schema $schema): void
34 | {
35 | // no down
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Middleware/MiddlewareDispatcherInterface.php:
--------------------------------------------------------------------------------
1 |
25 | */
26 | public function findAll(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): array;
27 |
28 | public function findByIntegrator(string $elementType, int $elementId, string $integrator, string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): ?ElementMetaDataInterface;
29 | }
30 |
--------------------------------------------------------------------------------
/public/js/metaData/integrator/ogIntegrator.js:
--------------------------------------------------------------------------------
1 | pimcore.registerNS('Seo.MetaData.Integrator.OpenGraphIntegrator');
2 | Seo.MetaData.Integrator.OpenGraphIntegrator = Class.create(Seo.MetaData.Integrator.AbstractPropertyIntegrator, {
3 |
4 | fieldSetTitle: t('seo_bundle.integrator.og.title'),
5 | iconClass: 'seo_integrator_icon_icon_og',
6 | fieldType: 'property',
7 | fieldTypeProperty: 'og:type',
8 | imageAwareTypes: ['og:image'],
9 | previewFields: {
10 | 'og:description': 'description',
11 | 'og:title': 'title',
12 | 'og:image': 'image',
13 | },
14 | addFieldButtonLabel: t('seo_bundle.integrator.og.add_field'),
15 |
16 | generateAdditionalToolbarElements: function (items) {
17 | items.push({
18 | xtype: 'container',
19 | flex: 1,
20 | html: t('seo_bundle.integrator.og.url_note'),
21 | style: {
22 | padding: '5px',
23 | border: '1px solid #A4E8A6',
24 | background: '#dde8c9',
25 | margin: '0 0 10px 0',
26 | color: 'black'
27 | }
28 | });
29 |
30 | return items;
31 | },
32 |
33 | });
--------------------------------------------------------------------------------
/templates/preview/ogGraph/facebook.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Preview
7 |
8 |
9 |
10 |
11 |
12 |
13 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/config/doctrine/model/QueueEntry.orm.yml:
--------------------------------------------------------------------------------
1 | SeoBundle\Model\QueueEntry:
2 | type: entity
3 | table: seo_queue_entry
4 | id:
5 | uuid:
6 | unique: true
7 | column: uuid
8 | type: uuid
9 | generator:
10 | strategy: CUSTOM
11 | customIdGenerator:
12 | class: Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator
13 | fields:
14 | type:
15 | column: '`type`'
16 | nullable: false
17 | type: string
18 | dataType:
19 | column: data_type
20 | nullable: false
21 | type: string
22 | dataId:
23 | column: data_id
24 | type: integer
25 | nullable: false
26 | dataUrl:
27 | column: data_url
28 | nullable: false
29 | type: text
30 | worker:
31 | column: worker
32 | nullable: false
33 | type: string
34 | resourceProcessor:
35 | column: resource_processor
36 | nullable: false
37 | type: string
38 | creationDate:
39 | column: creation_date
40 | type: datetime
41 | nullable: false
--------------------------------------------------------------------------------
/public/css/integrator/default.css:
--------------------------------------------------------------------------------
1 | html {
2 | font-family: sans-serif;
3 | -webkit-text-size-adjust: 100%;
4 | -ms-text-size-adjust: 100%;
5 | margin:20px;
6 | background: #ebebeb;
7 | }
8 |
9 | body {
10 | margin: 0
11 | }
12 |
13 | .invalid {
14 | color: #999999;
15 | }
16 |
17 | a:focus {
18 | outline: thin dotted
19 | }
20 |
21 | a:active,
22 | a:hover {
23 | outline: 0
24 | }
25 |
26 | h1 {
27 | font-size: 2em
28 | }
29 |
30 | abbr[title] {
31 | border-bottom: 1px dotted
32 | }
33 |
34 | b,
35 | strong {
36 | font-weight: bold
37 | }
38 |
39 | dfn {
40 | font-style: italic
41 | }
42 |
43 | mark {
44 | background: #ff0;
45 | color: #000
46 | }
47 |
48 | code,
49 | kbd,
50 | pre,
51 | samp {
52 | font-family: monospace, serif;
53 | font-size: 1em
54 | }
55 |
56 | small {
57 | font-size: 80%
58 | }
59 |
60 | sub,
61 | sup {
62 | font-size: 75%;
63 | line-height: 0;
64 | position: relative;
65 | vertical-align: baseline
66 | }
67 |
68 | sup {
69 | top: -0.5em
70 | }
71 |
72 | sub {
73 | bottom: -0.25em
74 | }
75 |
76 | img {
77 | border: 0
78 | }
79 |
80 | svg:not(:root) {
81 | overflow: hidden
82 | }
83 |
84 | figure {
85 | margin: 0
86 | }
87 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/ThirdParty/RemoveNewsMetaDataListenerPass.php:
--------------------------------------------------------------------------------
1 | getParameter('seo.third_party.enabled'), true)) {
24 | return;
25 | }
26 |
27 | $definition = 'NewsBundle\EventListener\MetaDataListener';
28 |
29 | if ($container->hasDefinition($definition)) {
30 | $container->removeDefinition($definition);
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Model/ElementMetaDataInterface.php:
--------------------------------------------------------------------------------
1 | dataProcessor->process([]);
37 |
38 | return self::SUCCESS;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/ResourceProcessor/ResourceProcessorInterface.php:
--------------------------------------------------------------------------------
1 | getTable('seo_element_meta_data')->hasIndex('element_type_id_integrator')) {
31 | return;
32 | }
33 |
34 | $this->addSql('DROP INDEX element_type_id_integrator ON seo_element_meta_data;');
35 | $this->addSql('CREATE UNIQUE INDEX element_type_id_integrator ON seo_element_meta_data (element_type, element_id, integrator, release_type);');
36 | }
37 |
38 | public function down(Schema $schema): void
39 | {
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/config/services/meta_data_integrator.yaml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | _defaults:
4 | autowire: true
5 | autoconfigure: true
6 | public: false
7 |
8 | SeoBundle\Registry\MetaDataIntegratorRegistryInterface: '@SeoBundle\Registry\MetaDataIntegratorRegistry'
9 | SeoBundle\Registry\MetaDataIntegratorRegistry: ~
10 |
11 | SeoBundle\Manager\ElementMetaDataManagerInterface: '@SeoBundle\Manager\ElementMetaDataManager'
12 | SeoBundle\Manager\ElementMetaDataManager:
13 | arguments:
14 | $integratorConfiguration: '%seo.meta_data_integrator.configuration%'
15 |
16 | SeoBundle\MetaData\Integrator\TitleDescriptionIntegrator:
17 | tags:
18 | - {name: seo.meta_data.integrator, identifier: title_description }
19 |
20 | SeoBundle\MetaData\Integrator\OpenGraphIntegrator:
21 | tags:
22 | - {name: seo.meta_data.integrator, identifier: open_graph }
23 |
24 | SeoBundle\MetaData\Integrator\TwitterCardIntegrator:
25 | tags:
26 | - {name: seo.meta_data.integrator, identifier: twitter_card }
27 |
28 | SeoBundle\MetaData\Integrator\SchemaIntegrator:
29 | tags:
30 | - {name: seo.meta_data.integrator, identifier: schema }
31 |
32 | SeoBundle\MetaData\Integrator\HtmlTagIntegrator:
33 | tags:
34 | - {name: seo.meta_data.integrator, identifier: html_tag }
--------------------------------------------------------------------------------
/src/Twig/Extension/SeoExtension.php:
--------------------------------------------------------------------------------
1 | metaDataProvider = $metaDataProvider;
27 | }
28 |
29 | public function getFunctions(): array
30 | {
31 | return [
32 | new TwigFunction('seo_update_metadata', [$this, 'updateMetadata']),
33 | ];
34 | }
35 |
36 | public function updateMetadata(mixed $element, ?string $locale): void
37 | {
38 | $this->metaDataProvider->updateSeoElement($element, $locale);
39 | }
40 |
41 | public function getName(): string
42 | {
43 | return 'seo_metadata';
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/MetaData/Integrator/IntegratorInterface.php:
--------------------------------------------------------------------------------
1 | getTable('seo_element_meta_data')->hasColumn('release_type')) {
31 | return;
32 | }
33 |
34 | $this->addSql('ALTER TABLE seo_element_meta_data ADD release_type VARCHAR(255) DEFAULT "public" NOT NULL;');
35 | $this->addSql('DROP INDEX element_type_id_integrator ON seo_element_meta_data;');
36 | $this->addSql('CREATE UNIQUE INDEX element_type_id_integrator ON seo_element_meta_data (element_type, element_id, integrator, release_type);');
37 | }
38 |
39 | public function down(Schema $schema): void
40 | {
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Repository/QueueEntryRepository.php:
--------------------------------------------------------------------------------
1 | repository = $entityManager->getRepository(QueueEntry::class);
28 | }
29 |
30 | public function findAll(?array $orderBy = null): array
31 | {
32 | return $this->repository->findBy([], $orderBy);
33 | }
34 |
35 | public function findAllForWorker(string $workerName, ?array $orderBy = null): array
36 | {
37 | return $this->repository->findBy(['worker' => $workerName], $orderBy);
38 | }
39 |
40 | public function findAtLeastOneForWorker(string $workerName): ?QueueEntryInterface
41 | {
42 | return $this->repository->findOneBy(['worker' => $workerName]);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # License
2 | Copyright (C) DACHCOM.DIGITAL
3 |
4 | This software is available under two different licenses:
5 | * GNU General Public License version 3 (GPLv3) as Pimcore Community Edition
6 | * DACHCOM Commercial License (DCL)
7 |
8 | The default SEO Bundle license, without a valid DACHCOM Commercial License agreement, is the Open-Source GPLv3 license.
9 |
10 | ## GNU General Public License version 3 (GPLv3)
11 | If you decide to choose the GPLv3 license, you must comply with the following terms:
12 |
13 | This program is free software: you can redistribute it and/or modify
14 | it under the terms of the GNU General Public License as published by
15 | the Free Software Foundation, either version 3 of the License, or
16 | (at your option) any later version.
17 |
18 | This program is distributed in the hope that it will be useful,
19 | but WITHOUT ANY WARRANTY; without even the implied warranty of
20 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 | GNU General Public License for more details.
22 |
23 | You should have received a copy of the GNU General Public License
24 | along with this program. If not, see .
25 |
26 | ## DACHCOM Commercial License (DCL)
27 | Alternatively, commercial and supported versions of the program - also known as
28 | Commercial Distributions - must be used in accordance with the terms and conditions
29 | contained in a separate written agreement between you and DACHCOM.DIGITAL AG.
30 | For more information about the SEO Bundle Commercial License (DCL) please contact dcdi@dachcom.ch.
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dachcom-digital/seo",
3 | "type": "pimcore-bundle",
4 | "license": [
5 | "GPL-3.0-or-later",
6 | "proprietary"
7 | ],
8 | "description": "Pimcore SEO Enrichment Bundle",
9 | "keywords": ["pimcore", "seo"],
10 | "homepage": "https://github.com/dachcom-digital/pimcore-seo",
11 | "authors": [
12 | {
13 | "name": "DACHCOM.DIGITAL Stefan Hagspiel",
14 | "email": "shagspiel@dachcom.ch",
15 | "homepage": "http://www.dachcom.com/",
16 | "role": "Developer"
17 | }
18 | ],
19 | "autoload": {
20 | "psr-4": {
21 | "SeoBundle\\": "src/"
22 | }
23 | },
24 | "autoload-dev": {
25 | "psr-4": {
26 | "": "src/"
27 | }
28 | },
29 | "extra": {
30 | "pimcore": {
31 | "bundles": [
32 | "SeoBundle\\SeoBundle"
33 | ]
34 | }
35 | },
36 | "require": {
37 | "ext-dom": "*",
38 | "pimcore/pimcore": "^11.0",
39 | "google/apiclient": "^2.12",
40 | "doctrine/orm": "^2.7"
41 | },
42 | "require-dev": {
43 | "codeception/codeception": "^5.0",
44 | "codeception/module-symfony": "^3.1",
45 | "codeception/module-webdriver": "^4.0",
46 | "phpstan/phpstan": "^2.0",
47 | "phpstan/phpstan-symfony": "^2.0",
48 | "symplify/easy-coding-standard": "~12.2.0"
49 | },
50 | "suggest": {
51 | "dachcom-digital/schema": "^3.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Registry/IndexWorkerRegistry.php:
--------------------------------------------------------------------------------
1 | services[$identifier] = $service;
31 | }
32 |
33 | public function has($identifier): bool
34 | {
35 | return isset($this->services[$identifier]);
36 | }
37 |
38 | public function get($identifier): IndexWorkerInterface
39 | {
40 | if (!$this->has($identifier)) {
41 | throw new \Exception('"' . $identifier . '" Index Worker does not exist');
42 | }
43 |
44 | return $this->services[$identifier];
45 | }
46 |
47 | public function getAll(): array
48 | {
49 | return $this->services;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/MetaMiddlewareAdapterPass.php:
--------------------------------------------------------------------------------
1 | getDefinition(MiddlewareDispatcher::class);
27 |
28 | foreach ($container->findTaggedServiceIds('seo.meta_data.middleware.adapter', true) as $serviceId => $attributes) {
29 | foreach ($attributes as $attribute) {
30 | if (!isset($attribute['identifier'])) {
31 | throw new InvalidArgumentException(sprintf('Attribute "identifier" missing for meta middleware "%s".', $serviceId));
32 | }
33 | $definition->addMethodCall('registerMiddlewareAdapter', [$attribute['identifier'], new Reference($serviceId)]);
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/EventListener/ElementMetaDataListener.php:
--------------------------------------------------------------------------------
1 | 'handleObjectDeletion',
33 | DocumentEvents::PRE_DELETE => 'handleDocumentDeletion',
34 | ];
35 | }
36 |
37 | public function handleDocumentDeletion(DocumentEvent $event): void
38 | {
39 | $this->elementMetaDataManager->deleteElementData('document', $event->getDocument()->getId(), null);
40 | }
41 |
42 | public function handleObjectDeletion(DataObjectEvent $event): void
43 | {
44 | $this->elementMetaDataManager->deleteElementData('object', $event->getObject()->getId(), null);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/css/integrator/title-description.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: sans-serif;
3 | -webkit-text-size-adjust: 100%;
4 | -ms-text-size-adjust: 100%;
5 | margin:20px;
6 | background: #ebebeb;
7 | }
8 |
9 | #snippet-box {
10 | border: 1px solid #ccc;
11 | border-radius: 3px;
12 | padding: 30px;
13 | margin: 28px 0 60px 0;
14 | box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
15 | background: white;
16 | }
17 |
18 | .snippet-title {
19 | color: rgb(26, 13, 171);
20 | cursor: pointer;
21 | font-family: arial, sans-serif;
22 | font-size: 18px;
23 | font-weight: 400;
24 | line-height: 21.6px;
25 | text-align: left;
26 | text-decoration: none;
27 | text-decoration-color: rgb(26, 13, 171);
28 | text-decoration-line: none;
29 | text-decoration-style: solid;
30 | white-space: nowrap;
31 | display: inline-block;
32 | }
33 |
34 | .snippet-url {
35 | color: rgb(0, 102, 33);
36 | font-family: arial, sans-serif;
37 | font-size: 14px;
38 | font-style: normal;
39 | font-weight: 400;
40 | line-height: 16px;
41 | text-align: left;
42 | white-space: nowrap;
43 | display: inline-block;
44 | }
45 |
46 | .snippet-text {
47 | color: rgb(84, 84, 84);
48 | font-family: arial, sans-serif;
49 | font-size: 13px;
50 | font-weight: 400;
51 | line-height: 18.2px;
52 | overflow-wrap: break-word;
53 | text-align: left;
54 | display: inline-block;;
55 | }
56 |
57 | .snippet-rich {
58 | box-sizing: border-box;
59 | clear: left;
60 | color: rgb(68, 68, 68);
61 | font-family: Arial, Verdana, Tahoma;
62 | font-size: 13px;
63 | font-weight: 400;
64 | line-height: 25px;
65 | display: inline;
66 | }
--------------------------------------------------------------------------------
/public/img/integrator/icon_schema.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Registry/MetaDataExtractorRegistry.php:
--------------------------------------------------------------------------------
1 | services[$identifier] = $service;
31 | }
32 |
33 | public function has($identifier): bool
34 | {
35 | return isset($this->services[$identifier]);
36 | }
37 |
38 | public function get($identifier): ExtractorInterface
39 | {
40 | if (!$this->has($identifier)) {
41 | throw new \Exception('"' . $identifier . '" Meta Data Extractor does not exist');
42 | }
43 |
44 | return $this->services[$identifier];
45 | }
46 |
47 | public function getAll(): array
48 | {
49 | return $this->services;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Registry/MetaDataIntegratorRegistry.php:
--------------------------------------------------------------------------------
1 | services[$identifier] = $service;
31 | }
32 |
33 | public function has($identifier): bool
34 | {
35 | return isset($this->services[$identifier]);
36 | }
37 |
38 | public function get($identifier): IntegratorInterface
39 | {
40 | if (!$this->has($identifier)) {
41 | throw new \Exception('"' . $identifier . '" Meta Data Integrator does not exist');
42 | }
43 |
44 | return $this->services[$identifier];
45 | }
46 |
47 | public function getAll(): array
48 | {
49 | return $this->services;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Registry/ResourceProcessorRegistry.php:
--------------------------------------------------------------------------------
1 | services[$identifier] = $service;
31 | }
32 |
33 | public function has($identifier): bool
34 | {
35 | return isset($this->services[$identifier]);
36 | }
37 |
38 | public function get($identifier): ResourceProcessorInterface
39 | {
40 | if (!$this->has($identifier)) {
41 | throw new \Exception('"' . $identifier . '" Resource Processor does not exist');
42 | }
43 |
44 | return $this->services[$identifier];
45 | }
46 |
47 | public function getAll(): array
48 | {
49 | return $this->services;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/MetaData/Extractor/ThirdParty/News/EntryMetaExtractor.php:
--------------------------------------------------------------------------------
1 | headMetaGenerator = $headMetaGenerator;
28 | }
29 |
30 | public function supports(mixed $element): bool
31 | {
32 | return $element instanceof EntryInterface;
33 | }
34 |
35 | public function updateMetadata(mixed $element, ?string $locale, SeoMetaDataInterface $seoMetadata): void
36 | {
37 | $seoMetadata->setMetaDescription($this->headMetaGenerator->generateDescription($element));
38 | $seoMetadata->setTitle($this->headMetaGenerator->generateTitle($element));
39 |
40 | foreach ($this->headMetaGenerator->generateMeta($element) as $property => $content) {
41 | if (!empty($content)) {
42 | $seoMetadata->addExtraProperty($property, $content);
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Worker/WorkerResponse.php:
--------------------------------------------------------------------------------
1 | status = $status;
29 | $this->message = $message;
30 | $this->successFullyProcessed = $successFullyProcessed;
31 | $this->queueEntry = $queueEntry;
32 | $this->rawResponse = $rawResponse;
33 | }
34 |
35 | public function getStatus(): int
36 | {
37 | return $this->status;
38 | }
39 |
40 | public function getMessage(): string
41 | {
42 | return $this->message;
43 | }
44 |
45 | public function getQueueEntry(): QueueEntryInterface
46 | {
47 | return $this->queueEntry;
48 | }
49 |
50 | public function getRawResponse(): mixed
51 | {
52 | return $this->rawResponse;
53 | }
54 |
55 | public function isDone(): bool
56 | {
57 | return $this->successFullyProcessed === true;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/MetaData/Extractor/ThirdParty/CoreShop/TitleDescriptionExtractor.php:
--------------------------------------------------------------------------------
1 | getMetaTitle($locale))) {
29 | $seoMetadata->setTitle($element->getMetaTitle($locale));
30 | } elseif (method_exists($element, 'getName') && !empty($element->getName($locale))) {
31 | $seoMetadata->setTitle($element->getName($locale));
32 | }
33 |
34 | if (method_exists($element, 'getMetaDescription') && !empty($element->getMetaDescription($locale))) {
35 | $seoMetadata->setMetaDescription($element->getMetaDescription($locale));
36 | } elseif (method_exists($element, 'getShortDescription') && !empty($element->getShortDescription($locale))) {
37 | $seoMetadata->setMetaDescription($element->getShortDescription($locale));
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/templates/preview/titleDescription/preview.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Preview
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
17 |
20 |
21 |
 }})
22 |
 }})
23 |
 }})
24 |
 }})
25 |
 }})
26 | Bewertung: 4,1 - 1.488 Rezensionen
27 |
28 |
29 | {{ author }} - {{ date }}
30 | {{ description }}
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/Manager/ElementMetaDataManagerInterface.php:
--------------------------------------------------------------------------------
1 |
26 | */
27 | public function getElementData(string $elementType, int $elementId, bool $allowDraftReleaseType = false): array;
28 |
29 | public function getElementDataForBackend(string $elementType, int $elementId): array;
30 |
31 | public function getElementDataForXliffExport(string $elementType, int $elementId, string $locale): array;
32 |
33 | public function saveElementDataFromXliffImport(string $elementType, int $elementId, array $rawData, string $locale): void;
34 |
35 | public function saveElementData(
36 | string $elementType,
37 | int $elementId,
38 | string $integratorName,
39 | array $data,
40 | bool $merge = false,
41 | string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC
42 | ): void;
43 |
44 | public function generatePreviewDataForElement(string $elementType, int $elementId, string $integratorName, ?string $template, array $data): array;
45 |
46 | public function deleteElementData(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): void;
47 | }
48 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/ThirdParty/RemoveCoreShopExtractorListenerPass.php:
--------------------------------------------------------------------------------
1 | getParameter('seo.third_party.enabled'), true)) {
24 | return;
25 | }
26 |
27 | $definitions = [
28 | 'coreshop.seo.extractor.description' => 'CoreShop\Component\SEO\Extractor\DescriptionExtractor',
29 | 'coreshop.seo.extractor.title' => 'CoreShop\Component\SEO\Extractor\TitleExtractor',
30 | 'coreshop.seo.extractor.og' => 'CoreShop\Component\SEO\Extractor\OGExtractor',
31 | 'coreshop.seo.extractor.image' => 'CoreShop\Component\SEO\Extractor\ImageExtractor',
32 | 'coreshop.seo.extractor.document' => 'CoreShop\Component\SEO\Extractor\DocumentExtractor'
33 | ];
34 |
35 | foreach ($definitions as $aliasDefinition => $definition) {
36 | if ($container->hasAlias($aliasDefinition)) {
37 | $container->removeAlias($aliasDefinition);
38 | }
39 |
40 | if ($container->hasDefinition($definition)) {
41 | $container->removeDefinition($definition);
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/public/img/seo_icon_white.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/MetaDataExtractorPass.php:
--------------------------------------------------------------------------------
1 | getDefinition(MetaDataExtractorRegistry::class);
29 |
30 | foreach ($container->findTaggedServiceIds('seo.meta_data.extractor', true) as $serviceId => $attributes) {
31 | foreach ($attributes as $attribute) {
32 | $priority = $attribute['priority'] ?? 0;
33 | $services[] = [$priority, ++$i, $serviceId, $attribute];
34 | }
35 | }
36 |
37 | uasort($services, static function ($a, $b) {
38 | return $b[0] <=> $a[0] ?: $a[1] <=> $b[1];
39 | });
40 |
41 | foreach ($services as [, $index, $serviceId, $attributes]) {
42 | if (!isset($attributes['identifier'])) {
43 | throw new InvalidArgumentException(sprintf('Attribute "identifier" missing for meta data extractor "%s".', $serviceId));
44 | }
45 |
46 | $definition->addMethodCall('register', [new Reference($serviceId), $attributes['identifier']]);
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/ResourceProcessorPass.php:
--------------------------------------------------------------------------------
1 | getDefinition(ResourceProcessorRegistry::class);
29 |
30 | foreach ($container->findTaggedServiceIds('seo.index.resource_processor', true) as $serviceId => $attributes) {
31 | foreach ($attributes as $attribute) {
32 | $priority = $attribute['priority'] ?? 0;
33 | $services[] = [$priority, ++$i, $serviceId, $attribute];
34 | }
35 | }
36 |
37 | uasort($services, static function ($a, $b) {
38 | return $b[0] <=> $a[0] ?: $a[1] <=> $b[1];
39 | });
40 |
41 | foreach ($services as [, $index, $serviceId, $attributes]) {
42 | if (!isset($attributes['identifier'])) {
43 | throw new InvalidArgumentException(sprintf('Attribute "identifier" missing for resource processor "%s".', $serviceId));
44 | }
45 |
46 | $definition->addMethodCall('register', [new Reference($serviceId), $attributes['identifier']]);
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Model/ElementMetaData.php:
--------------------------------------------------------------------------------
1 | id = $id;
28 | }
29 |
30 | public function getId(): ?int
31 | {
32 | return $this->id;
33 | }
34 |
35 | public function setElementType(string $elementType): void
36 | {
37 | $this->elementType = $elementType;
38 | }
39 |
40 | public function getElementType(): string
41 | {
42 | return $this->elementType;
43 | }
44 |
45 | public function setElementId($elementId): void
46 | {
47 | $this->elementId = $elementId;
48 | }
49 |
50 | public function getElementId(): int
51 | {
52 | return $this->elementId;
53 | }
54 |
55 | public function setIntegrator(string $integrator): void
56 | {
57 | $this->integrator = $integrator;
58 | }
59 |
60 | public function getIntegrator(): string
61 | {
62 | return $this->integrator;
63 | }
64 |
65 | public function setData(array $data): void
66 | {
67 | $this->data = $data;
68 | }
69 |
70 | public function getData(): array
71 | {
72 | return $this->data;
73 | }
74 |
75 | public function getReleaseType(): string
76 | {
77 | return $this->releaseType;
78 | }
79 |
80 | public function setReleaseType(string $releaseType): void
81 | {
82 | $this->releaseType = $releaseType;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/MetaData/Integrator/AbstractIntegrator.php:
--------------------------------------------------------------------------------
1 | findData($data, $requestedLocale, $returnType);
30 |
31 | if ($value !== null) {
32 | return $value;
33 | }
34 |
35 | foreach (Tool::getFallbackLanguagesFor($requestedLocale) as $fallBackLocale) {
36 | if (null !== $fallBackValue = $this->findData($data, $fallBackLocale, $returnType)) {
37 | return $fallBackValue;
38 | }
39 | }
40 |
41 | return null;
42 | }
43 |
44 | protected function findData(mixed $data, string $locale, string $returnType = 'scalar'): mixed
45 | {
46 | if (!is_array($data)) {
47 | return $data;
48 | }
49 |
50 | if (count($data) === 0) {
51 | return null;
52 | }
53 |
54 | $index = array_search($locale, array_column($data, 'locale'), true);
55 | if ($index === false) {
56 | return null;
57 | }
58 |
59 | $value = $data[$index]['value'];
60 |
61 | if (empty($value)) {
62 | return null;
63 | }
64 |
65 | if ($returnType === 'scalar' && !is_scalar($value)) {
66 | return null;
67 | }
68 |
69 | if ($returnType === 'array' && !is_array($value)) {
70 | return null;
71 | }
72 |
73 | return $value;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Repository/ElementMetaDataRepository.php:
--------------------------------------------------------------------------------
1 | repository = $entityManager->getRepository(ElementMetaData::class);
29 | }
30 |
31 | public function getQueryBuilder(): QueryBuilder
32 | {
33 | return $this->repository->createQueryBuilder('e');
34 | }
35 |
36 | public function findAll(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): array
37 | {
38 | $conditions = [
39 | 'elementType' => $elementType,
40 | 'elementId' => $elementId
41 | ];
42 |
43 | if ($releaseType !== null) {
44 | $conditions['releaseType'] = $releaseType;
45 | }
46 |
47 | return $this->repository->findBy($conditions);
48 | }
49 |
50 | public function findByIntegrator(
51 | string $elementType,
52 | int $elementId,
53 | string $integrator,
54 | string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC
55 | ): ?ElementMetaDataInterface {
56 | return $this->repository->findOneBy([
57 | 'elementType' => $elementType,
58 | 'elementId' => $elementId,
59 | 'integrator' => $integrator,
60 | 'releaseType' => $releaseType,
61 | ]);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/UPGRADE.md:
--------------------------------------------------------------------------------
1 | # Upgrade Notes
2 |
3 | ## 3.2.2
4 | - [BUGFIX] Xliff Export: pass null values as empty string
5 |
6 | ## 3.2.1
7 | - [BUGFIX] Check published state for objects
8 |
9 | ## 3.2.0
10 | - [LICENSE] Dual-License with GPL and Dachcom Commercial License (DCL) added
11 | - [ENHANCEMENT] Google Worker: Use new namespaces
12 |
13 | ## 3.1.3
14 | - **[ENHANCEMENT]** Allow no auth_config for Google Index Worker [@dpfaffenbauer](https://github.com/dachcom-digital/pimcore-seo/pull/69)
15 | - **[BUGFIX]** Fix Migration and Installer
16 |
17 | ## 3.1.2
18 | - **[BUGFIX]** Improve Migrations
19 |
20 | ## 3.1.1
21 | - **[BUGFIX]** Fix installer script
22 |
23 | ## 3.1.0
24 | - **[NEW FEATURE]** Add Release Type to allow draft/public states [@64](https://github.com/dachcom-digital/pimcore-seo/issues/64)
25 |
26 | ## 3.0.3
27 | - Fix Symfony Console deprecation in QueuedIndexDataCommand [@NiklasBr](https://github.com/dachcom-digital/pimcore-seo/pull/63)
28 |
29 | ## 3.0.2
30 | - Fix og:image URL for CoreShop third party og tag [@breakone ](https://github.com/dachcom-digital/pimcore-seo/pull/61)
31 | - FAdd ext-dom to composer.json [@NiklasBr](https://github.com/dachcom-digital/pimcore-seo/pull/51)
32 |
33 | ## 3.0.1
34 | - Skip meta data update when elementId is missing [@NiklasBr](https://github.com/dachcom-digital/pimcore-seo/pull/58)
35 |
36 | ## Migrating from Version 2.x to Version 3.0.0
37 | - Execute: `bin/console doctrine:migrations:migrate --prefix 'SeoBundle\Migrations'`
38 |
39 | ### Global Changes
40 | - Recommended folder structure by symfony adopted
41 | - SEO changes are not getting persisted at auto-save events anymore
42 |
43 | ### New Features
44 | - Xliff Import/Export Support, see [#31](https://github.com/dachcom-digital/pimcore-seo/issues/31)
45 | - Introduced `XliffAwareIntegratorInterface` to specify xliff translation states for given integrator
46 | - Properties for `OpenGraph` and `TwitterCard` integrator can be extended by an 3. argument to include/exclude them for xliff translations (Default `false`)
47 | - Seo Document Editor Support, see [#54](https://github.com/dachcom-digital/pimcore-seo/issues/54)
48 |
49 | ***
50 |
51 | SeoBundle 2.x Upgrade Notes: https://github.com/dachcom-digital/pimcore-seo/blob/2.x/UPGRADE.md
52 |
--------------------------------------------------------------------------------
/src/Model/SeoMetaDataInterface.php:
--------------------------------------------------------------------------------
1 | getOGTitle($locale))) {
30 | $seoMetadata->addExtraProperty('og:title', $element->getOGTitle($locale));
31 | } elseif (method_exists($element, 'getName') && !empty($element->getName($locale))) {
32 | $seoMetadata->addExtraProperty('og:title', $element->getName($locale));
33 | }
34 |
35 | if (method_exists($element, 'getOGDescription') && !empty($element->getOGDescription($locale))) {
36 | $seoMetadata->addExtraProperty('og:description', $element->getOGDescription($locale));
37 | } elseif (method_exists($element, 'getShortDescription') && !empty($element->getShortDescription($locale))) {
38 | $seoMetadata->addExtraProperty('og:description', $element->getShortDescription($locale));
39 | }
40 |
41 | if (method_exists($element, 'getOGType') && !empty($element->getOGType())) {
42 | $seoMetadata->addExtraProperty('og:type', $element->getOGType());
43 | }
44 |
45 | if (method_exists($element, 'getImage') && !empty($element->getImage())) {
46 | $path = $element->getImage()->getThumbnail('coreshop_seo');
47 | $ogImage = (str_starts_with('http', $path) ? '' : Tool::getHostUrl()) . $path;
48 | $seoMetadata->addExtraProperty('og:image', $ogImage);
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/translations/admin.en.yml:
--------------------------------------------------------------------------------
1 | seo_bundle.panel_title: 'SEO'
2 | seo_bundle.panel.draft_note: 'Unpublished Version!'
3 | seo_bundle.panel.error_fetch_data: 'Error while fetching seo metadata.'
4 | seo_bundle.panel.error_save_data: 'Error while saving seo metadata.'
5 | seo_bundle.panel.default_pimcore_disabled: 'The default SEO Section has been disabled. Use the "SEO" Panel instead.'
6 | seo_bundle.integrator.preview_template: 'Preview Template'
7 | seo_bundle.integrator.property.add_field: 'Add Field'
8 | seo_bundle.integrator.property.add_preset: 'Add Preset'
9 | seo_bundle.integrator.property.label_content: 'Content'
10 | seo_bundle.integrator.property.label_type: 'Type'
11 | seo_bundle.integrator.property.asset_path: 'Asset Path'
12 | seo_bundle.integrator.localized.locale: 'Locale'
13 | seo_bundle.integrator.localized.edit: 'Edit'
14 | seo_bundle.integrator.localized.save: 'Save'
15 | seo_bundle.integrator.localized.cancel: 'Cancel'
16 | seo_bundle.integrator.html.caution_note: 'With great power comes great responsibility! Please do not add raw entities unless it is really necessary!'
17 | seo_bundle.integrator.html.tag: 'Tag'
18 | seo_bundle.integrator.html.title: 'HTML-Tags'
19 | seo_bundle.integrator.html.add_field: 'Add HTML Tag Field'
20 | seo_bundle.integrator.og.title: 'Open Graph Editor'
21 | seo_bundle.integrator.og.add_field: 'Add OG Field'
22 | seo_bundle.integrator.og.url_note: 'Please note that "og:url" is not available in the property list. It will be generated automatically!'
23 | seo_bundle.integrator.schema.title: 'Schema Blocks'
24 | seo_bundle.integrator.schema.usage_note: 'HTML tags are not allowed and will be removed when saving. Valid data starts with {0} and ends with {1}'
25 | seo_bundle.integrator.schema.caution_note: 'Note: If you are adding custom schema fields, you should be aware of possible duplicate entries. There might be some dynamically added blocks which cannot be merged automatically.'
26 | seo_bundle.integrator.schema.add_field: 'Add Schema Field'
27 | seo_bundle.integrator.schema.data: 'Schema Data'
28 | seo_bundle.integrator.title_description.title: 'Title, Description'
29 | seo_bundle.integrator.title_description.single_title: 'Title'
30 | seo_bundle.integrator.title_description.single_description: 'Description'
31 | seo_bundle.integrator.twitter.title: 'Twitter Card'
32 | seo_bundle.integrator.twitter.add_field: 'Add Twitter Field'
33 | seo_bundle_add_property: 'SEO Bundle: Add Property'
34 | seo_bundle_remove_property: 'SEO Bundle: Remove Property'
35 |
--------------------------------------------------------------------------------
/src/EventListener/Admin/AssetListener.php:
--------------------------------------------------------------------------------
1 | 'addCssFiles',
26 | BundleManagerEvents::JS_PATHS => 'addJsFiles',
27 | ];
28 | }
29 |
30 | public function addCssFiles(PathsEvent $event): void
31 | {
32 | $event->addPaths([
33 | '/bundles/seo/css/admin.css'
34 | ]);
35 | }
36 |
37 | public function addJsFiles(PathsEvent $event): void
38 | {
39 | $event->addPaths([
40 | '/bundles/seo/js/plugin.js',
41 | '/bundles/seo/js/metaData/extension/localizedFieldExtension.js',
42 | '/bundles/seo/js/metaData/extension/integratorValueFetcher.js',
43 | '/bundles/seo/js/metaData/extension/hrefFieldExtension.js',
44 | '/bundles/seo/js/metaData/components/seoHrefTextField.js',
45 | '/bundles/seo/js/metaData/abstractMetaDataPanel.js',
46 | '/bundles/seo/js/metaData/documentMetaDataPanel.js',
47 | '/bundles/seo/js/metaData/objectMetaDataPanel.js',
48 | '/bundles/seo/js/metaData/integrator/abstractIntegrator.js',
49 | '/bundles/seo/js/metaData/integrator/titleDescriptionIntegrator.js',
50 | '/bundles/seo/js/metaData/integrator/htmlTagIntegrator.js',
51 | '/bundles/seo/js/metaData/integrator/schemaIntegrator.js',
52 | '/bundles/seo/js/metaData/integrator/abstractPropertyIntegrator.js',
53 | '/bundles/seo/js/metaData/integrator/propertyIntegrator/item.js',
54 | '/bundles/seo/js/metaData/integrator/twitterCardIntegrator.js',
55 | '/bundles/seo/js/metaData/integrator/ogIntegrator.js',
56 | ]);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Tool/LocaleProvider.php:
--------------------------------------------------------------------------------
1 | userResolver->getUser();
30 | if (!$user instanceof User) {
31 | return Tool::getValidLanguages();
32 | }
33 |
34 | if (!$object instanceof DataObject\AbstractObject) {
35 | return $this->sortLocalesByUserDefinition($user, Tool::getValidLanguages());
36 | }
37 |
38 | if ($user->isAdmin()) {
39 | return $this->sortLocalesByUserDefinition($user, Tool::getValidLanguages());
40 | }
41 |
42 | $allowedView = DataObject\Service::getLanguagePermissions($object, $user, 'lView');
43 | $allowedEdit = DataObject\Service::getLanguagePermissions($object, $user, 'lEdit');
44 |
45 | if ($allowedEdit === null) {
46 | return $this->sortLocalesByUserDefinition($user, Tool::getValidLanguages());
47 | }
48 |
49 | return $this->sortLocalesByUserDefinition($user, array_keys($allowedEdit));
50 | }
51 |
52 | protected function sortLocalesByUserDefinition(User $user, array $locales): array
53 | {
54 | $contentLanguages = $user->getContentLanguages();
55 |
56 | if (count($contentLanguages) === 0) {
57 | return $locales;
58 | }
59 |
60 | $orderIdKeys = array_flip($contentLanguages);
61 |
62 | usort($locales, static function ($l1, $l2) use ($orderIdKeys) {
63 | if (!isset($orderIdKeys[$l1], $orderIdKeys[$l2])) {
64 | return 0;
65 | }
66 |
67 | return strcmp($orderIdKeys[$l1], $orderIdKeys[$l2]);
68 | });
69 |
70 | return $locales;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/translations/admin.de.yml:
--------------------------------------------------------------------------------
1 | seo_bundle.panel_title: 'SEO'
2 | seo_bundle.panel.draft_note: 'Unveröffentlichte Version!'
3 | seo_bundle.panel.error_fetch_data: 'Fehler beim Abrufen der SEO-Metadaten.'
4 | seo_bundle.panel.error_save_data: 'Fehler beim Speichern der SEO-Metadaten.'
5 | seo_bundle.panel.default_pimcore_disabled: 'Der Standard-SEO-Bereich wurde deaktiviert. Verwenden Sie stattdessen das "SEO"-Panel.'
6 | seo_bundle.integrator.preview_template: 'Vorschau-Vorlage'
7 | seo_bundle.integrator.property.add_field: 'Feld hinzufügen'
8 | seo_bundle.integrator.property.add_preset: 'Vorlage hinzufügen'
9 | seo_bundle.integrator.property.label_content: 'Content'
10 | seo_bundle.integrator.property.label_type: 'Typ'
11 | seo_bundle.integrator.property.asset_path: 'Asset-Pfad'
12 | seo_bundle.integrator.localized.locale: 'Lokalisierung'
13 | seo_bundle.integrator.localized.edit: 'Bearbeiten'
14 | seo_bundle.integrator.localized.save: 'Speichern'
15 | seo_bundle.integrator.localized.cancel: 'Abbrechen'
16 | seo_bundle.integrator.html.caution_note: 'Mit großer Macht kommt große Verantwortung! Bitte fügen Sie keine Tag-Elemente hinzu, es sei denn, es ist wirklich notwendig!'
17 | seo_bundle.integrator.html.tag: 'Tag'
18 | seo_bundle.integrator.html.title: 'HTML-Tags'
19 | seo_bundle.integrator.html.add_field: 'HTML-Tag-Feld hinzufügen'
20 | seo_bundle.integrator.og.title: 'Open Graph Editor'
21 | seo_bundle.integrator.og.add_field: 'OG-Feld hinzufügen'
22 | seo_bundle.integrator.og.url_note: 'Bitte beachten Sie, dass das Feld "og:url" in der Objektliste nicht verfügbar ist. Sie wird automatisch generiert!'
23 | seo_bundle.integrator.schema.title: 'Schema Blöcke'
24 | seo_bundle.integrator.schema.usage_note: 'HTML-Tags sind nicht erlaubt und werden beim Speichern entfernt. Gültige Daten beginnen mit {0} und enden mit {1}'
25 | seo_bundle.integrator.schema.caution_note: 'Hinweis: Wenn Sie benutzerdefinierte Schemafelder hinzufügen, sollten Sie auf mögliche doppelte Einträge achten. Es könnte einige dynamisch hinzugefügte Blöcke geben, die nicht automatisch zusammengeführt werden können.'
26 | seo_bundle.integrator.schema.add_field: 'Schema-Feld hinzufügen'
27 | seo_bundle.integrator.schema.data: 'Schema-Daten'
28 | seo_bundle.integrator.title_description.title: 'Titel, Beschreibung'
29 | seo_bundle.integrator.title_description.single_title: 'Titel'
30 | seo_bundle.integrator.title_description.single_description: 'Beschreibung'
31 | seo_bundle.integrator.twitter.title: 'Twitter Card'
32 | seo_bundle.integrator.twitter.add_field: 'Twitter-Feld hinzufügen'
33 | seo_bundle_add_property: 'SEO Bundle: Property hinzufügen'
34 | seo_bundle_remove_property: 'SEO Bundle: Property entfernen'
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/IndexWorkerPass.php:
--------------------------------------------------------------------------------
1 | getDefinition(IndexWorkerRegistry::class);
29 |
30 | foreach ($container->findTaggedServiceIds('seo.index.worker', true) as $id => $tags) {
31 | foreach ($tags as $attributes) {
32 | $workerConfiguration = sprintf('seo.index.worker.config.%s', $attributes['identifier']);
33 | if (!$container->hasParameter($workerConfiguration)) {
34 | continue;
35 | }
36 |
37 | $workerConfiguration = $container->getParameter($workerConfiguration);
38 | $definition->addMethodCall('register', [new Reference($id), $attributes['identifier']]);
39 | $this->setDefinitionConfiguration($attributes['identifier'], $workerConfiguration, $container->getDefinition($id));
40 | }
41 | }
42 | }
43 |
44 | public function setDefinitionConfiguration(string $identifier, array $workerConfiguration, Definition $definition): void
45 | {
46 | $options = new OptionsResolver();
47 | $class = $definition->getClass();
48 |
49 | if (is_string($class) && is_subclass_of($class, IndexWorkerInterface::class)) {
50 | $class::configureOptions($options);
51 | }
52 |
53 | try {
54 | $resolvedOptions = $options->resolve($workerConfiguration);
55 | } catch (\Throwable $e) {
56 | throw new \Exception(sprintf('Invalid "%s" worker options. %s', $identifier, $e->getMessage()));
57 | }
58 |
59 | $definition->addMethodCall('setConfiguration', [$resolvedOptions]);
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/EventListener/AutoMetaDataAttachListener.php:
--------------------------------------------------------------------------------
1 | ['onKernelRequest', -255]
40 | ];
41 | }
42 |
43 | public function onKernelRequest(RequestEvent $event): void
44 | {
45 | $request = $event->getRequest();
46 |
47 | if (php_sapi_name() === 'cli') {
48 | return;
49 | }
50 |
51 | if ($this->configuration['auto_detect_documents'] === false) {
52 | return;
53 | }
54 |
55 | if ($event->isMainRequest() === false) {
56 | return;
57 | }
58 |
59 | if ($this->pimcoreContextResolver->matchesPimcoreContext($request, PimcoreContextResolver::CONTEXT_ADMIN)) {
60 | return;
61 | }
62 |
63 | if (!$this->pimcoreContextResolver->matchesPimcoreContext($request, PimcoreContextResolver::CONTEXT_DEFAULT)) {
64 | return;
65 | }
66 |
67 | if ($this->requestHelper->isFrontendRequestByAdmin($request)) {
68 | return;
69 | }
70 |
71 | $document = $this->documentResolverService->getDocument($request);
72 | if (!$document instanceof Page) {
73 | return;
74 | }
75 |
76 | $this->metaDataProvider->updateSeoElement($document, $request->getLocale());
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Tool/Install.php:
--------------------------------------------------------------------------------
1 | resolver = $resolver;
36 | }
37 |
38 | public function setSerializer(DecoderInterface $serializer): void
39 | {
40 | $this->serializer = $serializer;
41 | }
42 |
43 | public function install(): void
44 | {
45 | $this->installDbStructure();
46 | $this->installPermissions();
47 |
48 | parent::install();
49 | }
50 |
51 | public function getLastMigrationVersionClassName(): ?string
52 | {
53 | return Version20240827080929::class;
54 | }
55 |
56 | protected function installDbStructure(): void
57 | {
58 | $db = \Pimcore\Db::get();
59 | $db->executeQuery(file_get_contents($this->getInstallSourcesPath() . '/sql/install.sql'));
60 | }
61 |
62 | protected function installPermissions(): void
63 | {
64 | foreach (self::REQUIRED_PERMISSION as $permission) {
65 | $definition = Permission\Definition::getByKey($permission);
66 |
67 | if ($definition) {
68 | continue;
69 | }
70 |
71 | try {
72 | Permission\Definition::create($permission);
73 | } catch (\Throwable $e) {
74 | throw new InstallationException(sprintf('Failed to create permission "%s": %s', $permission, $e->getMessage()));
75 | }
76 | }
77 | }
78 |
79 | protected function getInstallSourcesPath(): string
80 | {
81 | return __DIR__ . '/../../config/install';
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Model/QueueEntry.php:
--------------------------------------------------------------------------------
1 | uuid = $uuid;
30 | }
31 |
32 | public function getUuid(): string
33 | {
34 | return $this->uuid;
35 | }
36 |
37 | public function setType(string $type): void
38 | {
39 | $this->type = $type;
40 | }
41 |
42 | public function getType(): string
43 | {
44 | return $this->type;
45 | }
46 |
47 | public function setDataId($dataId): void
48 | {
49 | $this->dataId = $dataId;
50 | }
51 |
52 | public function getDataId(): int
53 | {
54 | return $this->dataId;
55 | }
56 |
57 | public function setDataType(string $dataType): void
58 | {
59 | $this->dataType = $dataType;
60 | }
61 |
62 | public function getDataType(): string
63 | {
64 | return $this->dataType;
65 | }
66 |
67 | public function setDataUrl(string $dataUrl): void
68 | {
69 | $this->dataUrl = $dataUrl;
70 | }
71 |
72 | public function getDataUrl(): string
73 | {
74 | return $this->dataUrl;
75 | }
76 |
77 | public function setWorker(string $worker): void
78 | {
79 | $this->worker = $worker;
80 | }
81 |
82 | public function getWorker(): string
83 | {
84 | return $this->worker;
85 | }
86 |
87 | public function setResourceProcessor(string $resourceProcessor): void
88 | {
89 | $this->resourceProcessor = $resourceProcessor;
90 | }
91 |
92 | public function getResourceProcessor(): string
93 | {
94 | return $this->resourceProcessor;
95 | }
96 |
97 | public function setCreationDate(\DateTime $date): void
98 | {
99 | $this->creationDate = $date;
100 | }
101 |
102 | public function getCreationDate(): \DateTime
103 | {
104 | return $this->creationDate;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/Middleware/MiddlewareDispatcher.php:
--------------------------------------------------------------------------------
1 | middleware = [];
27 | $this->middlewareAdapterStack = [];
28 | $this->tasks = [];
29 | }
30 |
31 | public function registerMiddlewareAdapter(string $identifier, MiddlewareAdapterInterface $middlewareAdapter): void
32 | {
33 | $this->middlewareAdapterStack[$identifier] = $middlewareAdapter;
34 | }
35 |
36 | public function buildMiddleware(string $identifier, SeoMetaDataInterface $seoMetaData): MiddlewareInterface
37 | {
38 | if (!isset($this->middlewareAdapterStack[$identifier])) {
39 | throw new \Exception(sprintf('SEO MetaData middleware "%s" not registered.', $identifier));
40 | }
41 |
42 | if (isset($this->middleware[$identifier])) {
43 | return $this->middleware[$identifier];
44 | }
45 |
46 | $this->middlewareAdapterStack[$identifier]->boot();
47 | $this->middleware[$identifier] = new Middleware($identifier, $this);
48 |
49 | return $this->middleware[$identifier];
50 | }
51 |
52 | public function registerTask(callable $callback, string $identifier): void
53 | {
54 | $this->tasks[] = [
55 | 'identifier' => $identifier,
56 | 'callback' => $callback
57 | ];
58 | }
59 |
60 | public function dispatchTasks(SeoMetaDataInterface $seoMetadata): void
61 | {
62 | foreach ($this->tasks as $immediateTask) {
63 | $middlewareAdapter = $this->middlewareAdapterStack[$immediateTask['identifier']];
64 | call_user_func_array($immediateTask['callback'], array_merge([$seoMetadata], $middlewareAdapter->getTaskArguments()));
65 | }
66 |
67 | // reset tasks for next loop.
68 | $this->tasks = [];
69 | }
70 |
71 | public function dispatchMiddlewareFinisher(SeoMetaDataInterface $seoMetadata): void
72 | {
73 | foreach ($this->middleware as $identifier => $middleware) {
74 | $middlewareAdapter = $this->middlewareAdapterStack[$identifier];
75 | $middlewareAdapter->onFinish($seoMetadata);
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Queue/QueueDataProcessor.php:
--------------------------------------------------------------------------------
1 | isLocked(self::LOCK_KEY)) {
33 | return;
34 | }
35 |
36 | $this->lock(self::LOCK_KEY, 'queue index data processor via maintenance/command');
37 |
38 | try {
39 | $this->queueManager->processQueue();
40 | } catch (\Throwable $e) {
41 | $this->logger->log('error', sprintf('Error while processing queued index data. %s', $e->getMessage()));
42 | }
43 |
44 | $this->unlock(self::LOCK_KEY);
45 | }
46 |
47 | public function isLocked(string $token): bool
48 | {
49 | return $this->getLockToken($token) instanceof TmpStore;
50 | }
51 |
52 | public function getLockMessage(string $token): string
53 | {
54 | if (!$this->isLocked($token)) {
55 | return 'not-locked';
56 | }
57 |
58 | $tmpStore = $this->getLockToken($token);
59 | $startDate = date('m-d-Y H:i:s', $tmpStore->getDate());
60 | $failOverDate = date('m-d-Y H:i:s', $tmpStore->getExpiryDate());
61 | $executor = $tmpStore->getData();
62 |
63 | return sprintf(
64 | 'Process "%s" has been locked at %s by "%s" and will stay locked until process is finished with a self-delete failover at %s',
65 | $token,
66 | $startDate,
67 | $executor,
68 | $failOverDate
69 | );
70 | }
71 |
72 | protected function lock(string $token, string $executor, $lifeTime = 14400): void
73 | {
74 | if ($this->isLocked($token)) {
75 | return;
76 | }
77 |
78 | TmpStore::add($this->getNamespacedToken($token), $executor, null, $lifeTime);
79 | }
80 |
81 | protected function unlock(string $token): void
82 | {
83 | TmpStore::delete($this->getNamespacedToken($token));
84 | }
85 |
86 | protected function getLockToken(string $token): ?TmpStore
87 | {
88 | return TmpStore::get($this->getNamespacedToken($token));
89 | }
90 |
91 | protected function getNamespacedToken(string $token, string $namespace = 'seo'): string
92 | {
93 | return sprintf('%s_%s', $namespace, $token);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/MetaDataIntegratorPass.php:
--------------------------------------------------------------------------------
1 | getParameter('seo.meta_data_integrator.configuration');
30 |
31 | $definition = $container->getDefinition(MetaDataIntegratorRegistry::class);
32 | foreach ($container->findTaggedServiceIds('seo.meta_data.integrator', true) as $id => $tags) {
33 | foreach ($tags as $attributes) {
34 | if (!isset($attributes['identifier'])) {
35 | throw new InvalidArgumentException(sprintf('Attribute "identifier" missing for meta data integrator "%s".', $id));
36 | }
37 |
38 | $definition->addMethodCall('register', [new Reference($id), $attributes['identifier']]);
39 | $this->setDefinitionConfiguration($attributes['identifier'], $integratorConfiguration, $container->getDefinition($id));
40 | }
41 | }
42 | }
43 |
44 | public function setDefinitionConfiguration(string $identifier, array $integratorConfiguration, Definition $definition): void
45 | {
46 | $integratorConfig = null;
47 | foreach ($integratorConfiguration['enabled_integrator'] as $enabledIntegrator) {
48 | if ($enabledIntegrator['integrator_name'] === $identifier) {
49 | $integratorConfig = $enabledIntegrator['integrator_config'];
50 |
51 | break;
52 | }
53 | }
54 |
55 | if ($integratorConfig === null) {
56 | return;
57 | }
58 |
59 | $options = new OptionsResolver();
60 | $class = $definition->getClass();
61 |
62 | if (is_string($class) && is_subclass_of($class, IntegratorInterface::class)) {
63 | $class::configureOptions($options);
64 | }
65 |
66 | try {
67 | $resolvedOptions = $options->resolve($integratorConfig);
68 | } catch (\Throwable $e) {
69 | throw new \Exception(sprintf('Invalid "%s" meta data integrator options. %s', $identifier, $e->getMessage()));
70 | }
71 |
72 | $definition->addMethodCall('setConfiguration', [$resolvedOptions]);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/MetaData/Extractor/IntegratorExtractor.php:
--------------------------------------------------------------------------------
1 | integratorConfiguration = $integratorConfiguration;
35 | $this->elementMetaDataManager = $elementMetaDataManager;
36 | $this->metaDataIntegratorRegistry = $metaDataIntegratorRegistry;
37 | }
38 |
39 | public function supports(mixed $element): bool
40 | {
41 | if ($element instanceof Concrete) {
42 | if ($this->integratorConfiguration['objects']['enabled'] === false) {
43 | return false;
44 | }
45 |
46 | return in_array($element->getClassName(), $this->integratorConfiguration['objects']['data_classes'], true);
47 | }
48 |
49 | if ($element instanceof Page) {
50 | return $this->integratorConfiguration['documents']['enabled'] === true;
51 | }
52 |
53 | return false;
54 | }
55 |
56 | public function updateMetaData(mixed $element, ?string $locale, SeoMetaDataInterface $seoMetadata): void
57 | {
58 | $elementId = null;
59 | $elementType = null;
60 |
61 | if ($element instanceof Concrete) {
62 | $elementId = $element->getId();
63 | $elementType = 'object';
64 | } elseif ($element instanceof Document) {
65 | $elementId = $element->getId();
66 | $elementType = 'document';
67 | }
68 |
69 | if ($elementType === null || !$elementId) {
70 | return;
71 | }
72 |
73 | $elementMetaData = $this->elementMetaDataManager->getElementData($elementType, $elementId);
74 |
75 | foreach ($elementMetaData as $elementMeta) {
76 | try {
77 | $metaDataIntegrator = $this->metaDataIntegratorRegistry->get($elementMeta->getIntegrator());
78 | } catch (\Exception $e) {
79 | // fail silently
80 | continue;
81 | }
82 |
83 | $metaDataIntegrator->updateMetaData($element, $elementMeta->getData(), $locale, $seoMetadata);
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Tool/UrlGenerator.php:
--------------------------------------------------------------------------------
1 | requestStack = $requestStack;
28 | }
29 |
30 | public function generate($element, array $options = []): ?string
31 | {
32 | if ($element instanceof Page) {
33 | return $this->generateForDocument($element, $options);
34 | }
35 |
36 | if ($element instanceof Asset) {
37 | return $this->generateForAsset($element, $options);
38 | }
39 |
40 | if ($element instanceof DataObject\Concrete) {
41 | return $this->generateForObject($element, $options);
42 | }
43 |
44 | return null;
45 | }
46 |
47 | public function getCurrentSchemeAndHost(): string
48 | {
49 | return sprintf('%s://%s', $this->requestStack->getMainRequest()->getScheme(), $this->requestStack->getMainRequest()->getHost());
50 | }
51 |
52 | protected function generateForDocument(Page $document, array $options): ?string
53 | {
54 | try {
55 | $url = $document->getUrl();
56 | } catch (\Exception $e) {
57 | return null;
58 | }
59 |
60 | return $url;
61 | }
62 |
63 | protected function generateForObject(DataObject\Concrete $object, array $options): ?string
64 | {
65 | $linkGenerator = $object->getClass()->getLinkGenerator();
66 | if ($linkGenerator instanceof DataObject\ClassDefinition\LinkGeneratorInterface) {
67 | $link = $linkGenerator->generate($object, []);
68 | if (!str_contains($link, 'http')) {
69 | $link = sprintf('%s/%s', $this->getCurrentSchemeAndHost(), ltrim($link, '/'));
70 | }
71 |
72 | return $link;
73 | }
74 |
75 | return null;
76 | }
77 |
78 | protected function generateForAsset(Asset $asset, array $options): ?string
79 | {
80 | if (!$asset instanceof Asset\Image) {
81 | return null;
82 | }
83 |
84 | if (empty($options['thumbnail'])) {
85 | return null;
86 | }
87 |
88 | $thumbnail = $asset->getThumbnail($options['thumbnail']);
89 | if (!$thumbnail instanceof Asset\Image\Thumbnail) {
90 | return null;
91 | }
92 |
93 | $imagePath = $thumbnail->getPath(['deferredAllowed' => false]);
94 |
95 | if (str_contains($imagePath, 'http')) {
96 | return $imagePath;
97 | }
98 |
99 | return sprintf('%s/%s', $this->getCurrentSchemeAndHost(), ltrim($imagePath, '/'));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/SeoBundle.php:
--------------------------------------------------------------------------------
1 | container->get(Install::class);
39 | }
40 |
41 | public function build(ContainerBuilder $container): void
42 | {
43 | $this->configureDoctrineExtension($container);
44 |
45 | $container->addCompilerPass(new IndexWorkerPass());
46 | $container->addCompilerPass(new ResourceProcessorPass());
47 | $container->addCompilerPass(new MetaDataExtractorPass());
48 | $container->addCompilerPass(new MetaDataIntegratorPass());
49 | $container->addCompilerPass(new MetaMiddlewareAdapterPass());
50 |
51 | // third party handling
52 | $container->addCompilerPass(new RemoveNewsMetaDataListenerPass(), PassConfig::TYPE_BEFORE_REMOVING, 250);
53 | $container->addCompilerPass(new RemoveCoreShopExtractorListenerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 250);
54 | }
55 |
56 | public function getPath(): string
57 | {
58 | return \dirname(__DIR__);
59 | }
60 |
61 | protected function getComposerPackageName(): string
62 | {
63 | return self::PACKAGE_NAME;
64 | }
65 |
66 | protected function configureDoctrineExtension(ContainerBuilder $container): void
67 | {
68 | $container->addCompilerPass(
69 | DoctrineOrmMappingsPass::createYamlMappingDriver(
70 | [$this->getNameSpacePath() => $this->getNamespaceName()],
71 | ['seo.persistence.doctrine.manager'],
72 | 'seo.persistence.doctrine.enabled'
73 | )
74 | );
75 | }
76 |
77 | protected function getNamespaceName(): string
78 | {
79 | return 'SeoBundle\Model';
80 | }
81 |
82 | protected function getNameSpacePath(): string
83 | {
84 | return sprintf(
85 | '%s/config/doctrine/%s',
86 | $this->getPath(),
87 | 'model'
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/MetaData/Integrator/HtmlTagIntegrator.php:
--------------------------------------------------------------------------------
1 | false,
27 | 'livePreviewTemplates' => [],
28 | 'useLocalizedFields' => false,
29 | 'presets' => $this->configuration['presets'],
30 | 'presets_only_mode' => $this->configuration['presets_only_mode'],
31 | ];
32 | }
33 |
34 | public function getPreviewParameter(mixed $element, ?string $template, array $data): array
35 | {
36 | return [];
37 | }
38 |
39 | public function validateBeforeBackend(string $elementType, int $elementId, array $data): array
40 | {
41 | return $data;
42 | }
43 |
44 | public function validateBeforePersist(string $elementType, int $elementId, array $data, ?array $previousData = null, bool $merge = false): ?array
45 | {
46 | if (count($data) === 0) {
47 | return null;
48 | }
49 |
50 | foreach ($data as $index => $htmlTag) {
51 | if (!is_string($htmlTag)) {
52 | unset($data[$index]);
53 |
54 | continue;
55 | }
56 |
57 | // there must be some html tags in there.
58 | if ($htmlTag === strip_tags($htmlTag)) {
59 | unset($data[$index]);
60 |
61 | continue;
62 | }
63 | }
64 |
65 | $indexedData = array_values($data);
66 |
67 | if (count($indexedData) === 0) {
68 | return null;
69 | }
70 |
71 | return $indexedData;
72 | }
73 |
74 | public function updateMetaData(mixed $element, array $data, ?string $locale, SeoMetaDataInterface $seoMetadata): void
75 | {
76 | if (count($data) === 0) {
77 | return;
78 | }
79 |
80 | foreach ($data as $htmlTag) {
81 | if (is_string($htmlTag)) {
82 | $seoMetadata->addRaw($htmlTag);
83 | }
84 | }
85 | }
86 |
87 | public function setConfiguration(array $configuration): void
88 | {
89 | $this->configuration = $configuration;
90 | }
91 |
92 | public static function configureOptions(OptionsResolver $resolver): void
93 | {
94 | $resolver->setDefaults([
95 | 'presets_only_mode' => false,
96 | 'presets' => [],
97 | ]);
98 |
99 | $resolver->setRequired(['presets_only_mode']);
100 | $resolver->setAllowedTypes('presets_only_mode', ['bool']);
101 | $resolver->setAllowedTypes('presets', ['array']);
102 |
103 | $resolver->setDefault('presets', function (OptionsResolver $spoolResolver) {
104 | $spoolResolver->setPrototype(true);
105 | $spoolResolver->setRequired(['label', 'value']);
106 | $spoolResolver->setDefault('icon_class', null);
107 | $spoolResolver->setAllowedTypes('label', 'string');
108 | $spoolResolver->setAllowedTypes('value', 'string');
109 | $spoolResolver->setAllowedTypes('icon_class', ['string', 'null']);
110 | });
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/MetaData/MetaDataProvider.php:
--------------------------------------------------------------------------------
1 | getSeoMetaData($element, $locale);
36 |
37 | if ($extraProperties = $seoMetadata->getExtraProperties()) {
38 | foreach ($extraProperties as $key => $value) {
39 | $this->headMeta->appendProperty($key, $value);
40 | }
41 | }
42 |
43 | if ($extraNames = $seoMetadata->getExtraNames()) {
44 | foreach ($extraNames as $key => $value) {
45 | $this->headMeta->appendName($key, $value);
46 | }
47 | }
48 |
49 | if ($extraHttp = $seoMetadata->getExtraHttp()) {
50 | foreach ($extraHttp as $key => $value) {
51 | $this->headMeta->appendHttpEquiv($key, $value);
52 | }
53 | }
54 |
55 | if ($schemaBlocks = $seoMetadata->getSchema()) {
56 | foreach ($schemaBlocks as $schemaBlock) {
57 | if (is_array($schemaBlock)) {
58 | $schemaTag = sprintf('', json_encode($schemaBlock, JSON_UNESCAPED_UNICODE));
59 | $this->headMeta->addRaw($schemaTag);
60 | }
61 | }
62 | }
63 |
64 | if ($raw = $seoMetadata->getRaw()) {
65 | foreach ($raw as $rawValue) {
66 | $this->headMeta->addRaw($rawValue);
67 | }
68 | }
69 |
70 | if ($seoMetadata->getTitle()) {
71 | $this->headTitle->set($seoMetadata->getTitle());
72 | }
73 |
74 | if ($seoMetadata->getMetaDescription()) {
75 | $this->headMeta->setDescription($seoMetadata->getMetaDescription());
76 | }
77 | }
78 |
79 | protected function getSeoMetaData(mixed $element, ?string $locale): SeoMetaData
80 | {
81 | $seoMetaData = new SeoMetaData($this->middlewareDispatcher);
82 | $extractors = $this->getExtractorsForElement($element);
83 | foreach ($extractors as $extractor) {
84 | $extractor->updateMetadata($element, $locale, $seoMetaData);
85 | $this->middlewareDispatcher->dispatchTasks($seoMetaData);
86 | }
87 |
88 | $this->middlewareDispatcher->dispatchMiddlewareFinisher($seoMetaData);
89 |
90 | return $seoMetaData;
91 | }
92 |
93 | /**
94 | * @return array
95 | */
96 | protected function getExtractorsForElement($element): array
97 | {
98 | return array_filter($this->extractorRegistry->getAll(), static function (ExtractorInterface $extractor) use ($element) {
99 | return $extractor->supports($element);
100 | });
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/EventListener/Admin/XliffListener.php:
--------------------------------------------------------------------------------
1 | 'export',
39 | XliffEvents::XLIFF_ATTRIBUTE_SET_IMPORT => 'import',
40 | ];
41 | }
42 |
43 | public function export(TranslationXliffEvent $event): void
44 | {
45 | $attributeSet = $event->getAttributeSet();
46 | $element = $attributeSet->getTranslationItem()->getElement();
47 |
48 | $sourceLanguage = $attributeSet->getSourceLanguage();
49 | $elementType = $this->determinateType($element);
50 |
51 | if ($elementType === null) {
52 | return;
53 | }
54 |
55 | $metaData = $this->elementMetaDataManager->getElementDataForXliffExport($elementType, $element->getId(), $sourceLanguage);
56 |
57 | foreach ($metaData as $integrator => $integratorValues) {
58 | foreach ($integratorValues as $property => $value) {
59 | $attributeSet->addAttribute(
60 | self::XLIFF_TYPE,
61 | sprintf('%s#%s', $integrator, $property),
62 | $value ?? '',
63 | false,
64 | []
65 | );
66 | }
67 | }
68 | }
69 |
70 | public function import(TranslationXliffEvent $event): void
71 | {
72 | $attributeSet = $event->getAttributeSet();
73 | $element = $attributeSet->getTranslationItem()->getElement();
74 |
75 | if ($attributeSet->isEmpty()) {
76 | return;
77 | }
78 |
79 | $targetLanguage = $attributeSet->getTargetLanguages()[0];
80 | $elementType = $this->determinateType($element);
81 |
82 | if ($elementType === null) {
83 | return;
84 | }
85 |
86 | $rawData = [];
87 | foreach ($attributeSet->getAttributes() as $attribute) {
88 | if ($attribute->getType() !== self::XLIFF_TYPE) {
89 | continue;
90 | }
91 |
92 | $attributeName = $attribute->getName();
93 |
94 | [$integrator, $property] = explode('#', $attributeName);
95 |
96 | if (!array_key_exists($integrator, $rawData)) {
97 | $rawData[$integrator] = [];
98 | }
99 |
100 | $rawData[$integrator][$property] = $attribute->getContent();
101 | }
102 |
103 | $this->elementMetaDataManager->saveElementDataFromXliffImport($elementType, $element->getId(), $rawData, $targetLanguage);
104 | }
105 |
106 | private function determinateType(ElementInterface $element): ?string
107 | {
108 | if ($element instanceof Document) {
109 | return 'document';
110 | }
111 |
112 | if ($element instanceof DataObject) {
113 | return 'object';
114 | }
115 |
116 | return null;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/public/js/plugin.js:
--------------------------------------------------------------------------------
1 | class SeoCore {
2 | constructor() {
3 | this.ready = false;
4 | this.configuration = {};
5 | this.dataQueue = [];
6 |
7 | if (!String.prototype.format) {
8 | String.prototype.format = function () {
9 | const args = arguments;
10 | return this.replace(/{(\d+)}/g, function (match, number) {
11 | return typeof args[number] != 'undefined'
12 | ? args[number]
13 | : match
14 | ;
15 | });
16 | };
17 | }
18 | }
19 |
20 | getClassName() {
21 | return 'pimcore.plugin.Seo';
22 | }
23 |
24 | init() {
25 | Ext.Ajax.request({
26 | url: '/admin/seo/meta-data/get-meta-definitions',
27 | success: function (response) {
28 |
29 | const resp = Ext.decode(response.responseText);
30 |
31 | this.ready = true;
32 | this.configuration = resp.configuration;
33 | this.processQueue();
34 |
35 | }.bind(this)
36 | });
37 | }
38 |
39 | postOpenDocument(ev) {
40 |
41 | const document = ev.detail.document;
42 |
43 | if (this.ready) {
44 | this.processElement(document, 'page');
45 | } else {
46 | this.addElementToQueue(document, 'page');
47 | }
48 | }
49 |
50 | postOpenObject(ev) {
51 |
52 | const object = ev.detail.object;
53 |
54 | if (this.ready) {
55 | this.processElement(object, 'object');
56 | } else {
57 | this.addElementToQueue(object, 'object');
58 | }
59 | }
60 |
61 | postSaveDocument(ev) {
62 |
63 | const document = ev.detail.document;
64 |
65 | if (ev.detail.task === 'autoSave') {
66 | return;
67 | }
68 |
69 | if (document.hasOwnProperty('seoPanel')) {
70 | document.seoPanel.save(ev.detail.task);
71 | }
72 | }
73 |
74 | postSaveObject(ev) {
75 |
76 | const object = ev.detail.object;
77 |
78 | if (ev.detail.task === 'autoSave') {
79 | return;
80 | }
81 |
82 | if (object.hasOwnProperty('seoPanel')) {
83 | object.seoPanel.save(ev.detail.task);
84 | }
85 | }
86 |
87 | addElementToQueue(obj, type) {
88 | this.dataQueue.push({'obj': obj, 'type': type});
89 | }
90 |
91 | processQueue() {
92 |
93 | if (this.dataQueue.length > 0) {
94 | return;
95 | }
96 |
97 | Ext.each(this.dataQueue, function (data) {
98 |
99 | const obj = data.obj;
100 | const type = data.type;
101 |
102 | this.processElement(obj, type);
103 |
104 | }.bind(this));
105 |
106 | this.dataQueue = {};
107 | }
108 |
109 | processElement(obj, type) {
110 |
111 | if (type === 'object'
112 | && this.configuration.objects.enabled === true
113 | && this.configuration.objects.data_classes.indexOf(obj.data.general.className) !== -1) {
114 | obj.seoPanel = new Seo.MetaData.ObjectMetaDataPanel(obj, this.configuration);
115 | obj.seoPanel.setup(type);
116 | } else if (type === 'page'
117 | && this.configuration.documents.enabled === true
118 | && ['page'].indexOf(obj.type) !== -1) {
119 | obj.seoPanel = new Seo.MetaData.DocumentMetaDataPanel(obj, this.configuration);
120 | obj.seoPanel.setup(type, this.configuration.documents.hide_pimcore_default_seo_panel);
121 | }
122 | }
123 |
124 | }
125 |
126 | const seoCoreHandler = new SeoCore();
127 |
128 | document.addEventListener(pimcore.events.pimcoreReady, seoCoreHandler.init.bind(seoCoreHandler));
129 | document.addEventListener(pimcore.events.postOpenDocument, seoCoreHandler.postOpenDocument.bind(seoCoreHandler));
130 | document.addEventListener(pimcore.events.postOpenObject, seoCoreHandler.postOpenObject.bind(seoCoreHandler));
131 | document.addEventListener(pimcore.events.postSaveDocument, seoCoreHandler.postSaveDocument.bind(seoCoreHandler));
132 | document.addEventListener(pimcore.events.postSaveObject, seoCoreHandler.postSaveObject.bind(seoCoreHandler));
133 |
--------------------------------------------------------------------------------
/src/EventListener/Admin/SeoDocumentEditorListener.php:
--------------------------------------------------------------------------------
1 | 'adjustData',
41 | DocumentEvents::POST_UPDATE => 'updateData',
42 | ];
43 | }
44 |
45 | public function adjustData(GenericEvent $event): void
46 | {
47 | if (!$event->getSubject() instanceof DocumentController) {
48 | return;
49 | }
50 |
51 | $list = $event->getArgument('list');
52 |
53 | foreach ($list['data'] as $listIndex => $item) {
54 | if ($item['type'] !== 'page') {
55 | continue;
56 | }
57 |
58 | $metaData = array_values(
59 | array_filter(
60 | $this->elementMetaDataManager->getElementData('document', $item['id']),
61 | static function (ElementMetaData $integratorData) {
62 | return $integratorData->getIntegrator() === 'title_description';
63 | }
64 | )
65 | );
66 |
67 | if (count($metaData) === 0) {
68 | continue;
69 | }
70 |
71 | /** @var ElementMetaData $titleDescriptionIntegrator */
72 | $titleDescriptionIntegrator = $metaData[0];
73 | $titleDescriptionIntegratorData = $titleDescriptionIntegrator->getData();
74 |
75 | $list['data'][$listIndex]['title'] = $titleDescriptionIntegratorData['title'] ?? null;
76 | $list['data'][$listIndex]['description'] = $titleDescriptionIntegratorData['description'] ?? null;
77 | }
78 |
79 | $event->setArgument('list', $list);
80 | }
81 |
82 | public function updateData(DocumentEvent $event): void
83 | {
84 | $request = $this->requestStack->getMainRequest();
85 |
86 | if (!$request instanceof Request) {
87 | return;
88 | }
89 |
90 | $document = $event->getDocument();
91 | $attributes = $request->request->all();
92 | $hasTitleAttribute = $request->request->has('title');
93 | $hasDescriptionAttribute = $request->request->has('description');
94 |
95 | // crazy. but there is no other way to determinate, that we're updating via seo panel context...
96 | if (!$hasTitleAttribute || !$hasDescriptionAttribute || count($attributes) !== 4) {
97 | return;
98 | }
99 |
100 | $integratorData = [
101 | 'title' => $request->request->get('title'),
102 | 'description' => $request->request->get('description')
103 | ];
104 |
105 | $this->elementMetaDataManager->saveElementData(
106 | 'document',
107 | $document->getId(),
108 | 'title_description',
109 | $integratorData
110 | );
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/EventListener/PimcoreElementListener.php:
--------------------------------------------------------------------------------
1 | 'onDocumentPostUpdate',
39 | DocumentEvents::PRE_DELETE => 'onDocumentPreDelete',
40 | DataObjectEvents::POST_UPDATE => 'onObjectPostUpdate',
41 | DataObjectEvents::PRE_DELETE => 'onObjectPreDelete',
42 | AssetEvents::POST_ADD => 'onAssetPostAdd',
43 | AssetEvents::POST_UPDATE => 'onAssetPostUpdate',
44 | AssetEvents::PRE_DELETE => 'onAssetPreDelete',
45 | ];
46 | }
47 |
48 | public function onDocumentPostUpdate(DocumentEvent $event): void
49 | {
50 | if ($this->enabled === false) {
51 | return;
52 | }
53 |
54 | if ($event->getDocument()->getType() !== 'page') {
55 | return;
56 | }
57 |
58 | $dispatchType = $event->getDocument()->isPublished() === false
59 | ? IndexWorkerInterface::TYPE_DELETE
60 | : IndexWorkerInterface::TYPE_UPDATE;
61 |
62 | $this->queueManager->addToQueue($dispatchType, $event->getDocument());
63 | }
64 |
65 | public function onDocumentPreDelete(DocumentEvent $event): void
66 | {
67 | if ($this->enabled === false) {
68 | return;
69 | }
70 |
71 | if ($event->getDocument()->getType() !== 'page') {
72 | return;
73 | }
74 |
75 | $this->queueManager->addToQueue(IndexWorkerInterface::TYPE_DELETE, $event->getDocument());
76 | }
77 |
78 | public function onObjectPostUpdate(DataObjectEvent $event): void
79 | {
80 | if ($this->enabled === false) {
81 | return;
82 | }
83 |
84 | $object = $event->getObject();
85 | if (!$object instanceof Concrete) {
86 | return;
87 | }
88 |
89 | $dispatchType = $object->isPublished() === false
90 | ? IndexWorkerInterface::TYPE_DELETE
91 | : IndexWorkerInterface::TYPE_UPDATE;
92 |
93 | $this->queueManager->addToQueue($dispatchType, $event->getObject());
94 | }
95 |
96 | public function onObjectPreDelete(DataObjectEvent $event): void
97 | {
98 | if ($this->enabled === false) {
99 | return;
100 | }
101 |
102 | $this->queueManager->addToQueue(IndexWorkerInterface::TYPE_DELETE, $event->getObject());
103 | }
104 |
105 | public function onAssetPostAdd(AssetEvent $event): void
106 | {
107 | if ($this->enabled === false) {
108 | return;
109 | }
110 |
111 | $this->queueManager->addToQueue(IndexWorkerInterface::TYPE_ADD, $event->getAsset());
112 | }
113 |
114 | public function onAssetPostUpdate(AssetEvent $event): void
115 | {
116 | if ($this->enabled === false) {
117 | return;
118 | }
119 |
120 | $this->queueManager->addToQueue(IndexWorkerInterface::TYPE_UPDATE, $event->getAsset());
121 | }
122 |
123 | public function onAssetPreDelete(AssetEvent $event): void
124 | {
125 | if ($this->enabled === false) {
126 | return;
127 | }
128 |
129 | $this->queueManager->addToQueue(IndexWorkerInterface::TYPE_DELETE, $event->getAsset());
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pimcore SEO Bundle
2 | [](LICENSE.md)
3 | [](LICENSE.md)
4 | [](https://packagist.org/packages/dachcom-digital/seo)
5 | [](https://github.com/dachcom-digital/pimcore-seo/actions?query=workflow%3ACodeception+branch%3Amaster)
6 | [](https://github.com/dachcom-digital/pimcore-seo/actions?query=workflow%3A"PHP+Stan"+branch%3Amaster)
7 |
8 | The last SEO Bundle for pimcore you'll ever need!
9 |
10 | - Create title, description and meta tags (OG-Tags, Twitter-Cards) for documents **and** objects!
11 | - Shipped with a save and user-friendly editor with multi locale support!
12 | - Enjoy live previews of each social channel!
13 | - Super smooth and simple PHP-API to update meta information of documents or objects!
14 | - Submit content data to search engines like Google, Bing, DuckDuckGo in real time!
15 | - Fully backwards compatible if you're going to install this bundle within an existing pimcore instance!
16 |
17 | ## Documents
18 | 
19 |
20 | ## Objects
21 | 
22 |
23 | ## Objects | Tabbed View
24 | 
25 |
26 | ### Release Plan
27 |
28 | | Release | Supported Pimcore Versions | Supported Symfony Versions | Release Date | Maintained | Branch |
29 | |---------|----------------------------|----------------------------|--------------|----------------|--------|
30 | | **3.x** | `11.0` | `6.2` | 30.08.2023 | Feature Branch | master |
31 | | **2.x** | `10.1` - `10.6` | `5.4` | 14.10.2021 | Unsupported | 2.x |
32 | | **1.x** | `6.0` - `6.9` | `3.4`, `^4.4` | 27.04.2020 | Unsupported | 1.x |
33 |
34 |
35 | ## Installation
36 |
37 | ```json
38 | "require" : {
39 | "dachcom-digital/seo" : "~3.2.0",
40 | }
41 | ```
42 |
43 | Add Bundle to `bundles.php`:
44 | ```php
45 | return [
46 | SeoBundle\SeoBundle::class => ['all' => true],
47 | ];
48 | ```
49 |
50 | - Execute: `$ bin/console pimcore:bundle:install SeoBundle`
51 |
52 | ## Upgrading
53 | - Execute: `$ bin/console doctrine:migrations:migrate --prefix 'SeoBundle\Migrations'`
54 |
55 | ## Usage
56 | This Bundle needs some preparation. Please check out the [Setup && Overview](docs/00_Setup.md) guide first.
57 |
58 | ## Further Information
59 | - [Setup & Overview](docs/00_Setup.md)
60 | - [Meta Data](./docs/10_MetaData.md) [Set Title, Description, ...]
61 | - [Integrators](./docs/MetaData/10_Integrator.md)
62 | - [Title & Description Integrator](./docs/MetaData/Integrator/10_TitleDescriptionIntegrator.md)
63 | - [Open Graph Integrator](./docs/MetaData/Integrator/11_OpenGraphIntegrator.md)
64 | - [Twitter Card Integrator](./docs/MetaData/Integrator/12_TwitterCardIntegrator.md)
65 | - [Schema Integrator](./docs/MetaData/Integrator/13_SchemaIntegrator.md)
66 | - [HTML-Tag Integrator](./docs/MetaData/Integrator/14_HtmlTagIntegrator.md)
67 | - [Extractors](./docs/MetaData/20_Extractors.md)
68 | - [Custom Extractor](./docs/MetaData/Extractor/10_CustomExtractor.md)
69 | - [Third Party Extractors](./docs/MetaData/Extractor/11_ThirdPartyExtractors.md)
70 | - [Middleware](docs/MetaData/30_Middleware.md)
71 | - [Index Notification](docs/20_IndexNotification.md) [Push Data to Google Index]
72 | - [Google Worker](docs/IndexNotification/Worker/01_GoogleWorker.md) [Push Data to Google Index]
73 |
74 | ## Supported 3rd Party Bundles
75 | - Use [dachcom-digital/jobs](https://github.com/dachcom-digital/pimcore-jobs) to push job data via google index!
76 | - Use [dachcom-digital/schema](https://github.com/dachcom-digital/pimcore-schema) to generate schema blocks via PHP API with ease!
77 |
78 | ## Upgrade Info
79 | Before updating, please [check our upgrade notes!](UPGRADE.md)
80 |
81 | ## License
82 | **DACHCOM.DIGITAL AG**, Löwenhofstrasse 15, 9424 Rheineck, Schweiz
83 | [dachcom.com](https://www.dachcom.com), dcdi@dachcom.ch
84 | Copyright © 2024 DACHCOM.DIGITAL. All rights reserved.
85 |
86 | For licensing details please visit [LICENSE.md](LICENSE.md)
87 |
--------------------------------------------------------------------------------
/src/Controller/Admin/MetaDataController.php:
--------------------------------------------------------------------------------
1 | json([
37 | 'configuration' => $this->elementMetaDataManager->getMetaDataIntegratorConfiguration()
38 | ]);
39 | }
40 |
41 | /**
42 | * @throws \Exception
43 | */
44 | public function getElementMetaDataConfigurationAction(Request $request): JsonResponse
45 | {
46 | $element = null;
47 | $availableLocales = null;
48 |
49 | $elementId = (int) $request->query->get('elementId', 0);
50 | $elementType = $request->query->get('elementType');
51 |
52 | if ($elementType === 'object') {
53 | $element = DataObject::getById($elementId);
54 | $availableLocales = $this->localeProvider->getAllowedLocalesForObject($element);
55 | } elseif ($elementType === 'document') {
56 | $element = Document::getById($elementId);
57 | }
58 |
59 | $configuration = $this->elementMetaDataManager->getMetaDataIntegratorBackendConfiguration($element);
60 | $elementBackendData = $this->elementMetaDataManager->getElementDataForBackend($elementType, $elementId);
61 |
62 | return $this->adminJson(
63 | array_merge(
64 | [
65 | 'success' => true,
66 | 'availableLocales' => $availableLocales,
67 | 'configuration' => $configuration,
68 | ],
69 | $elementBackendData
70 | )
71 | );
72 | }
73 |
74 | /**
75 | * @throws \Exception
76 | */
77 | public function setElementMetaDataConfigurationAction(Request $request): JsonResponse
78 | {
79 | $elementId = (int) $request->request->get('elementId', 0);
80 | $elementType = $request->request->get('elementType');
81 | $task = $request->request->get('task', 'publish');
82 | $integratorValues = json_decode($request->request->get('integratorValues'), true, 512, JSON_THROW_ON_ERROR);
83 |
84 | if (!is_array($integratorValues)) {
85 | return $this->adminJson(['success' => true]);
86 | }
87 |
88 | foreach ($integratorValues as $integratorName => $integratorData) {
89 | $sanitizedData = is_array($integratorData) ? $integratorData : [];
90 | $releaseType = $task === 'publish' ? ElementMetaDataInterface::RELEASE_TYPE_PUBLIC : ElementMetaDataInterface::RELEASE_TYPE_DRAFT;
91 | $this->elementMetaDataManager->saveElementData($elementType, $elementId, $integratorName, $sanitizedData, false, $releaseType);
92 | }
93 |
94 | return $this->adminJson([
95 | 'success' => true
96 | ]);
97 | }
98 |
99 | /**
100 | * @throws \Exception
101 | */
102 | public function generateMetaDataPreviewAction(Request $request): Response
103 | {
104 | $elementId = (int) $request->query->get('elementId', 0);
105 | $elementType = $request->query->get('elementType', '');
106 |
107 | $template = $request->query->get('template', 'none');
108 | $integratorName = $request->query->get('integratorName');
109 | $data = json_decode($request->query->get('data', ''), true, 512, JSON_THROW_ON_ERROR);
110 |
111 | if (empty($data)) {
112 | $data = [];
113 | }
114 |
115 | $previewData = $this->elementMetaDataManager->generatePreviewDataForElement($elementType, $elementId, $integratorName, $template, $data);
116 |
117 | return $this->render($previewData['path'], $previewData['params']);
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/DependencyInjection/SeoExtension.php:
--------------------------------------------------------------------------------
1 | processConfiguration($configuration, $configs);
29 |
30 | $loader = new YamlFileLoader($container, new FileLocator([__DIR__ . '/../../config']));
31 | $loader->load('services.yaml');
32 |
33 | $this->validateConfiguration($config);
34 |
35 | $persistenceConfig = $config['persistence']['doctrine'];
36 | $entityManagerName = $persistenceConfig['entity_manager'];
37 |
38 | $enabledWorkerNames = [];
39 | foreach ($config['index_provider_configuration']['enabled_worker'] as $enabledWorker) {
40 | $enabledWorkerNames[] = $enabledWorker['worker_name'];
41 | $container->setParameter(sprintf('seo.index.worker.config.%s', $enabledWorker['worker_name']), $enabledWorker['worker_config']);
42 | }
43 |
44 | $container->setParameter('seo.persistence.doctrine.enabled', true);
45 | $container->setParameter('seo.persistence.doctrine.manager', $entityManagerName);
46 | $container->setParameter('seo.index.worker.enabled', $enabledWorkerNames);
47 | $container->setParameter('seo.meta_data_provider.configuration', $config['meta_data_configuration']['meta_data_provider']);
48 | $container->setParameter('seo.meta_data_integrator.configuration', $config['meta_data_configuration']['meta_data_integrator']);
49 | $container->setParameter('seo.index.pimcore_element_watcher.enabled', $config['index_provider_configuration']['pimcore_element_watcher']['enabled']);
50 | }
51 |
52 | public function prepend(ContainerBuilder $container): void
53 | {
54 | $configs = $container->getExtensionConfig($this->getAlias());
55 |
56 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
57 |
58 | $enabledThirdPartyConfigs = [];
59 |
60 | $xliffBundleEnabled = $container->hasExtension('pimcore_xliff');
61 | $pimcoreSeoBundleEnabled = $container->hasExtension('pimcore_seo');
62 | $newsBundleEnabled = $container->hasExtension('news');
63 | $coreShopSeoBundleEnabled = $container->hasExtension('core_shop_seo');
64 |
65 | foreach ($configs as $config) {
66 | $thirdPartyConfig = $config['meta_data_configuration']['meta_data_provider']['third_party'] ?? null;
67 |
68 | if ($thirdPartyConfig === null) {
69 | continue;
70 | }
71 |
72 | if ($coreShopSeoBundleEnabled && ($thirdPartyConfig['coreshop']['disable_default_extractors'] ?? false) === false) {
73 | $enabledThirdPartyConfigs['core_shop_seo'] = 'services/third_party/coreshop.yaml';
74 | }
75 |
76 | if ($newsBundleEnabled && ($thirdPartyConfig['news']['disable_default_extractors'] ?? false) === false) {
77 | $enabledThirdPartyConfigs['dachcom_news'] = 'services/third_party/news.yaml';
78 | }
79 | }
80 |
81 | if ($xliffBundleEnabled) {
82 | $enabledThirdPartyConfigs['pimcore_xliff'] = 'services/third_party/pimcore_xliff.yaml';
83 | }
84 |
85 | if ($pimcoreSeoBundleEnabled) {
86 | $enabledThirdPartyConfigs['pimcore_seo'] = 'services/third_party/pimcore_seo.yaml';
87 | }
88 |
89 | foreach ($enabledThirdPartyConfigs as $enabledThirdPartyConfig) {
90 | $loader->load($enabledThirdPartyConfig);
91 | }
92 |
93 | $container->setParameter('seo.third_party.enabled', array_keys($enabledThirdPartyConfigs));
94 | }
95 |
96 | private function validateConfiguration(array $config): void
97 | {
98 | $enabledIntegrators = [];
99 | foreach ($config['meta_data_configuration']['meta_data_integrator']['enabled_integrator'] as $dataIntegrator) {
100 | if (in_array($dataIntegrator['integrator_name'], $enabledIntegrators, true)) {
101 | throw new InvalidConfigurationException(sprintf('Meta data integrator "%s" already has been added', $dataIntegrator['integrator_name']));
102 | }
103 |
104 | $enabledIntegrators[] = $dataIntegrator['integrator_name'];
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Model/SeoMetaData.php:
--------------------------------------------------------------------------------
1 | middlewareDispatcher = $middlewareDispatcher;
39 | }
40 |
41 | public function setId(int $id): void
42 | {
43 | $this->id = $id;
44 | }
45 |
46 | public function getId(): int
47 | {
48 | return $this->id;
49 | }
50 |
51 | public function getMiddleware(string $middlewareAdapterName): MiddlewareInterface
52 | {
53 | return $this->middlewareDispatcher->buildMiddleware($middlewareAdapterName, $this);
54 | }
55 |
56 | public function setMetaDescription($metaDescription): void
57 | {
58 | $this->metaDescription = $metaDescription;
59 | }
60 |
61 | public function getMetaDescription(): string
62 | {
63 | return $this->metaDescription;
64 | }
65 |
66 | public function setOriginalUrl(string $originalUrl): void
67 | {
68 | $this->originalUrl = $originalUrl;
69 | }
70 |
71 | public function getOriginalUrl(): string
72 | {
73 | return $this->originalUrl;
74 | }
75 |
76 | public function setTitle($title): void
77 | {
78 | $this->title = $title;
79 | }
80 |
81 | public function getTitle(): string
82 | {
83 | return $this->title;
84 | }
85 |
86 | public function setExtraProperties(array|\Traversable $extraProperties): void
87 | {
88 | $this->extraProperties = $this->toArray($extraProperties);
89 | }
90 |
91 | public function getExtraProperties(): array
92 | {
93 | return $this->extraProperties;
94 | }
95 |
96 | public function addExtraProperty($key, $value): void
97 | {
98 | $this->extraProperties[$key] = (string) $value;
99 | }
100 |
101 | public function removeExtraProperty($key): void
102 | {
103 | if (array_key_exists($key, $this->extraProperties)) {
104 | unset($this->extraProperties[$key]);
105 | }
106 | }
107 |
108 | public function setExtraNames(array|\Traversable $extraNames): void
109 | {
110 | $this->extraNames = $this->toArray($extraNames);
111 | }
112 |
113 | public function getExtraNames(): array
114 | {
115 | return $this->extraNames;
116 | }
117 |
118 | public function addExtraName(string $key, string $value): void
119 | {
120 | $this->extraNames[$key] = $value;
121 | }
122 |
123 | public function removeExtraName(string $key): void
124 | {
125 | if (array_key_exists($key, $this->extraNames)) {
126 | unset($this->extraNames[$key]);
127 | }
128 | }
129 |
130 | public function setExtraHttp(array|\Traversable $extraHttp): void
131 | {
132 | $this->extraHttp = $this->toArray($extraHttp);
133 | }
134 |
135 | public function getExtraHttp(): array
136 | {
137 | return $this->extraHttp;
138 | }
139 |
140 | public function addExtraHttp(string $key, string $value): void
141 | {
142 | $this->extraHttp[$key] = (string) $value;
143 | }
144 |
145 | public function removeExtraHttp(string $key): void
146 | {
147 | if (array_key_exists($key, $this->extraHttp)) {
148 | unset($this->extraHttp[$key]);
149 | }
150 | }
151 |
152 | public function getSchema(): array
153 | {
154 | return $this->schema;
155 | }
156 |
157 | public function addSchema(array $schemaJsonLd): void
158 | {
159 | $this->schema[] = $schemaJsonLd;
160 | }
161 |
162 | public function getRaw(): array
163 | {
164 | return $this->raw;
165 | }
166 |
167 | public function addRaw(string $value): void
168 | {
169 | $this->raw[] = $value;
170 | }
171 |
172 | private function toArray(mixed $data): array
173 | {
174 | if (is_array($data)) {
175 | return $data;
176 | }
177 |
178 | if ($data instanceof \Traversable) {
179 | return iterator_to_array($data);
180 | }
181 |
182 | throw new \InvalidArgumentException(
183 | sprintf('Expected array or Traversable, got "%s"', is_object($data) ? get_class($data) : gettype($data))
184 | );
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/src/MetaData/Integrator/TitleDescriptionIntegrator.php:
--------------------------------------------------------------------------------
1 | true,
30 | 'livePreviewTemplates' => [],
31 | 'useLocalizedFields' => $element instanceof DataObject
32 | ];
33 | }
34 |
35 | public function getPreviewParameter(mixed $element, ?string $template, array $data): array
36 | {
37 | $url = 'http://localhost';
38 |
39 | try {
40 | $url = $element instanceof Page ? $element->getUrl() : 'http://localhost';
41 | } catch (\Exception $e) {
42 | // fail silently
43 | }
44 |
45 | $author = 'John Doe';
46 | $title = $data['title'] ?? 'This is a title';
47 | $description = $data['description'] ?? 'This is a very long description which should be not too long.';
48 |
49 | return [
50 | 'path' => '@Seo/preview/titleDescription/preview.html.twig',
51 | 'params' => [
52 | 'url' => $url,
53 | 'author' => $author,
54 | 'title' => $title,
55 | 'description' => $description,
56 | 'date' => date('d.m.Y')
57 | ]
58 | ];
59 | }
60 |
61 | public function validateBeforeBackend(string $elementType, int $elementId, array $data): array
62 | {
63 | return $data;
64 | }
65 |
66 | public function validateBeforeXliffExport(string $elementType, int $elementId, array $data, string $locale): array
67 | {
68 | $transformedData = $this->validateBeforeBackend($elementType, $elementId, $data);
69 |
70 | $exportData = [];
71 | foreach ($transformedData as $fieldName => $fieldData) {
72 | $exportData[$fieldName] = $this->findData($fieldData, $locale);
73 | }
74 |
75 | return $exportData;
76 | }
77 |
78 | public function validateBeforeXliffImport(string $elementType, int $elementId, array $data, string $locale): ?array
79 | {
80 | $parsedData = [];
81 |
82 | foreach ($data as $property => $value) {
83 | $parsedData[$property] = $elementType === 'object' ? [
84 | [
85 | 'locale' => $locale,
86 | 'value' => $value
87 | ]
88 | ] : $value;
89 | }
90 |
91 | return $parsedData;
92 | }
93 |
94 | public function validateBeforePersist(string $elementType, int $elementId, array $data, ?array $previousData = null, bool $merge = false): ?array
95 | {
96 | if ($elementType === 'object') {
97 | $data = $this->mergeStorageAndEditModeLocaleAwareData($data, $previousData, $merge);
98 | }
99 |
100 | if (empty($data['title']) && empty($data['description'])) {
101 | return null;
102 | }
103 |
104 | return $data;
105 | }
106 |
107 | protected function mergeStorageAndEditModeLocaleAwareData(array $data, ?array $previousData, bool $mergeWithPrevious = false): array
108 | {
109 | $arrayModifier = new ArrayHelper();
110 |
111 | // nothing to merge, just clean up
112 | if (!is_array($previousData) || count($previousData) === 0) {
113 | return [
114 | 'title' => $arrayModifier->cleanEmptyLocaleRows($data['title']),
115 | 'description' => $arrayModifier->cleanEmptyLocaleRows($data['description'])
116 | ];
117 | }
118 |
119 | $newData = $mergeWithPrevious ? $previousData : [];
120 |
121 | foreach (['title', 'description'] as $type) {
122 | $rebuildRow = $previousData[$type] ?? [];
123 |
124 | if (!isset($data[$type]) || !is_array($data[$type])) {
125 | $newData[$type] = $rebuildRow;
126 |
127 | continue;
128 | }
129 |
130 | $newData[$type] = $arrayModifier->rebuildLocaleValueRow($data[$type], $rebuildRow, $mergeWithPrevious);
131 | }
132 |
133 | return $newData;
134 | }
135 |
136 | public function updateMetaData(mixed $element, array $data, ?string $locale, SeoMetaDataInterface $seoMetadata): void
137 | {
138 | if (null !== $value = $this->findLocaleAwareData($data['description'] ?? null, $locale)) {
139 | $seoMetadata->setMetaDescription($value);
140 | }
141 |
142 | if (null !== $value = $this->findLocaleAwareData($data['title'] ?? null, $locale)) {
143 | $seoMetadata->setTitle($value);
144 | }
145 | }
146 |
147 | public function setConfiguration(array $configuration): void
148 | {
149 | $this->configuration = $configuration;
150 | }
151 |
152 | public static function configureOptions(OptionsResolver $resolver): void
153 | {
154 | // no options here.
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Helper/ArrayHelper.php:
--------------------------------------------------------------------------------
1 | cleanEmptyLocaleRows($data, $dataIdentifier);
53 |
54 | // nothing to merge
55 | if (!is_array($previousData) || count($previousData) === 0) {
56 | return $cleanedRows;
57 | }
58 |
59 | $newData = $mergeWithPrevious ? $previousData : [];
60 |
61 | $newDataIndex = 0;
62 |
63 | if ($cleanedRows === null) {
64 | return null;
65 | }
66 |
67 | foreach ($cleanedRows as $row) {
68 | $previousRowIndex = array_search($row[$rowIdentifier], array_column($previousData, $rowIdentifier), true);
69 |
70 | if ($previousRowIndex === false) {
71 | $newData[$newDataIndex] = $row;
72 | $newDataIndex++;
73 |
74 | continue;
75 | }
76 |
77 | $dataIndex = $mergeWithPrevious ? $previousRowIndex : $newDataIndex;
78 |
79 | $rebuildRow = $previousData[$previousRowIndex][$dataIdentifier];
80 | $currentValue = $row[$dataIdentifier];
81 |
82 | // it's not a localized field value
83 | if (!is_array($currentValue) || $this->isAssocArray($currentValue)) {
84 | $newData[$dataIndex] = $row;
85 | $newDataIndex++;
86 |
87 | continue;
88 | }
89 |
90 | $row[$dataIdentifier] = $this->rebuildLocaleValueRow($currentValue, $rebuildRow, $mergeWithPrevious);
91 |
92 | if (count($row[$dataIdentifier]) > 0) {
93 | $newData[$dataIndex] = $row;
94 | }
95 |
96 | $newDataIndex++;
97 | }
98 |
99 | return $newData;
100 | }
101 |
102 | public function rebuildLocaleValueRow(array $values, array $rebuildRow, bool $mergeWithPrevious = false): array
103 | {
104 | // clean-up rebuild row
105 | $allowedLocales = array_map(static function (array $row) {
106 | return $row['locale'];
107 | }, $values);
108 |
109 | $cleanedRebuildRow = [];
110 | foreach ($rebuildRow as $rebuildLine) {
111 | $locale = $rebuildLine['locale'];
112 |
113 | if ($mergeWithPrevious === false && !in_array($locale, $allowedLocales, true)) {
114 | continue;
115 | }
116 |
117 | if (!array_key_exists($locale, $cleanedRebuildRow)) {
118 | $cleanedRebuildRow[$locale] = $rebuildLine;
119 | }
120 | }
121 |
122 | $cleanedRebuildRow = array_values($cleanedRebuildRow);
123 |
124 | foreach ($values as $currentRow) {
125 | $locale = $currentRow['locale'];
126 | $value = $currentRow['value'];
127 |
128 | $index = array_search($locale, array_column($cleanedRebuildRow, 'locale'), true);
129 |
130 | if ($index !== false) {
131 | if ($value === null) {
132 | unset($cleanedRebuildRow[$index]);
133 | } else {
134 | $cleanedRebuildRow[$index] = $currentRow;
135 | }
136 | } elseif ($value !== null) {
137 | $cleanedRebuildRow[] = $currentRow;
138 | }
139 | }
140 |
141 | return array_values($cleanedRebuildRow);
142 | }
143 |
144 | public function cleanEmptyLocaleRows(array $field, string $dataIdentifier = 'value'): ?array
145 | {
146 | $cleanData = [];
147 | foreach ($field as $row) {
148 | if ($row[$dataIdentifier] === null) {
149 | continue;
150 | }
151 |
152 | if (is_array($row[$dataIdentifier]) && $this->isAssocArray($row[$dataIdentifier]) === false) {
153 | if (null !== $cleanRowValues = $this->cleanEmptyLocaleValues($row[$dataIdentifier])) {
154 | $cleanData[] = array_replace($row, [$dataIdentifier => $cleanRowValues]);
155 | }
156 |
157 | continue;
158 | }
159 |
160 | // it's not a localized field, keep it as it is
161 | $cleanData[] = $row;
162 | }
163 |
164 | return count($cleanData) === 0 ? null : $cleanData;
165 | }
166 |
167 | public function cleanEmptyLocaleValues(array $field): ?array
168 | {
169 | $cleanData = [];
170 | foreach ($field as $row) {
171 | if ($row['value'] !== null) {
172 | $cleanData[] = $row;
173 | }
174 | }
175 |
176 | return count($cleanData) === 0 ? null : $cleanData;
177 | }
178 |
179 | public function isAssocArray(array $array): bool
180 | {
181 | if ($array === []) {
182 | return false;
183 | }
184 |
185 | return !array_is_list($array);
186 | }
187 | }
188 |
--------------------------------------------------------------------------------