├── 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 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/integrator/icon_html_tags.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 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 | 3 | 4 | -------------------------------------------------------------------------------- /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 |
14 |
15 |
16 |
17 | {{ title }} 18 | {{ description }} 19 | {{ url }} 20 |
21 |
22 |
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 |
14 |
15 |
16 |
17 | {{ url }} 18 |
19 |
20 |
{{ title }}
21 |
22 | {{ description }} 23 |
24 |
25 |
26 |
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 | 2 | 3 | -------------------------------------------------------------------------------- /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 |
15 |
{{ title }}
16 |
17 |
18 |
{{ url }}
19 |
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 | 3 | 4 | 5 | 6 | 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 | [![Software License](https://img.shields.io/badge/license-GPLv3-brightgreen.svg?style=flat-square)](LICENSE.md) 3 | [![Software License](https://img.shields.io/badge/license-DCL-white.svg?style=flat-square&color=%23ff5c5c)](LICENSE.md) 4 | [![Latest Release](https://img.shields.io/packagist/v/dachcom-digital/seo.svg?style=flat-square)](https://packagist.org/packages/dachcom-digital/seo) 5 | [![Tests](https://img.shields.io/github/actions/workflow/status/dachcom-digital/pimcore-seo/.github/workflows/codeception.yml?branch=master&style=flat-square&logo=github&label=codeception)](https://github.com/dachcom-digital/pimcore-seo/actions?query=workflow%3ACodeception+branch%3Amaster) 6 | [![PhpStan](https://img.shields.io/github/actions/workflow/status/dachcom-digital/pimcore-seo/.github/workflows/php-stan.yml?branch=master&style=flat-square&logo=github&label=phpstan%20level%204)](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 | ![image](https://user-images.githubusercontent.com/700119/79641134-db71cd00-8195-11ea-81c4-e2bbdb7073f5.png) 19 | 20 | ## Objects 21 | ![image](https://user-images.githubusercontent.com/700119/79641347-39eb7b00-8197-11ea-9ef7-9ec41f8c2057.png) 22 | 23 | ## Objects | Tabbed View 24 | ![image](https://user-images.githubusercontent.com/700119/79804274-0578ea00-8364-11ea-8780-3cd8b2d72376.png) 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 | --------------------------------------------------------------------------------