diff --git a/AGENTS.md b/AGENTS.md index 412aa959..77233f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ The `docs/` directory contains **what** applies to this project: - `docs/deployment.md` - Hosting provider and deployment rules - `docs/releasing.md` - Version scheme and release process - `docs/sitemap.md` - XML sitemap module, coverage and generation +- `docs/seo.md` - Meta tags, social share cards and structured data - `docs/preview-links.md` - sharing unpublished content by link - `docs/faqs.md` - Project-specific FAQs diff --git a/composer.json b/composer.json index a6f96561..3c13142b 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "drupal/redis": "^1.11", "drupal/robotstxt": "^1.6", "drupal/scheduled_transitions": "^2.8.4", + "drupal/schema_metatag": "^3.0.4", "drupal/sdc_devel": "^1.0.3", "drupal/search_api": "^1.41", "drupal/search_api_solr": "^4.4.0", diff --git a/composer.lock b/composer.lock index 74df1691..6a7cd26a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a6d90396c70152e71e7d425ce2f9a56", + "content-hash": "eb42ddded282566231d9e1bc3e2cdd00", "packages": [ { "name": "asm89/stack-cors", @@ -5304,6 +5304,81 @@ "source": "https://git.drupalcode.org/project/scheduled_transitions" } }, + { + "name": "drupal/schema_metatag", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/schema_metatag.git", + "reference": "3.0.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/schema_metatag-3.0.4.zip", + "reference": "3.0.4", + "shasum": "bdc9ee4efc6a995e6b0e75a517e65e1355226c95" + }, + "require": { + "drupal/core": "^9 || ^10 || ^11", + "drupal/metatag": "^2.0", + "php": ">=8.0" + }, + "require-dev": { + "drupal/coder": "^8.3", + "drupal/metatag_views": "*", + "drupal/schema_article": "*", + "drupal/schema_organization": "*", + "ergebnis/composer-normalize": "*", + "mpyw/phpunit-patch-serializable-comparison": "*", + "phpcompatibility/php-compatibility": "^9.3" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "3.0.4", + "datestamp": "1771516335", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "damienmckenna", + "homepage": "https://www.drupal.org/user/108450" + }, + { + "name": "karens", + "homepage": "https://www.drupal.org/user/45874" + }, + { + "name": "thejimbirch", + "homepage": "https://www.drupal.org/user/2507260" + }, + { + "name": "wells", + "homepage": "https://www.drupal.org/user/2452278" + } + ], + "description": "Metatag implementation of Schema.org structured data (JSON-LD)", + "homepage": "https://www.drupal.org/project/schema_metatag", + "keywords": [ + "Drupal" + ], + "support": { + "source": "https://git.drupalcode.org/project/schema_metatag", + "issues": "https://www.drupal.org/project/issues/schema_metatag" + } + }, { "name": "drupal/sdc_devel", "version": "1.0.3", diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index 7e8dc9a8..db840c84 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -93,10 +93,15 @@ module: purge_ui: 0 recaptcha_v3: 0 redirect: 0 + redirect_404: 0 redis: 0 rest: 0 robotstxt: 0 scheduled_transitions: 0 + schema_article: 0 + schema_metatag: 0 + schema_organization: 0 + schema_web_site: 0 search_api: 0 search_api_db: 0 search_api_solr: 0 diff --git a/config/default/image.style.social_share.yml b/config/default/image.style.social_share.yml new file mode 100644 index 00000000..0587b46b --- /dev/null +++ b/config/default/image.style.social_share.yml @@ -0,0 +1,17 @@ +uuid: 9c47ccd3-afd5-4dfd-852d-6f3cfd21907f +langcode: en +status: true +dependencies: + module: + - focal_point +name: social_share +label: 'Social share' +effects: + 226e6628-7213-4c7e-bd0b-edaf917baf9f: + uuid: 226e6628-7213-4c7e-bd0b-edaf917baf9f + id: focal_point_scale_and_crop + weight: 1 + data: + width: 1200 + height: 630 + crop_type: focal_point diff --git a/config/default/metatag.metatag_defaults.front.yml b/config/default/metatag.metatag_defaults.front.yml index 5762e486..18968446 100644 --- a/config/default/metatag.metatag_defaults.front.yml +++ b/config/default/metatag.metatag_defaults.front.yml @@ -8,4 +8,5 @@ id: front label: 'Front page' tags: canonical_url: '[site:url]' + og_url: '[site:url]' shortlink: '[site:url]' diff --git a/config/default/metatag.metatag_defaults.global.yml b/config/default/metatag.metatag_defaults.global.yml index a75a2fa0..7caf1226 100644 --- a/config/default/metatag.metatag_defaults.global.yml +++ b/config/default/metatag.metatag_defaults.global.yml @@ -8,4 +8,18 @@ id: global label: Global tags: canonical_url: '[current-page:url]' + og_site_name: '[site:name]' + og_type: website + og_url: '[current-page:url]' + schema_organization_id: '[site:url]' + schema_organization_logo: 'a:4:{s:5:"@type";s:11:"ImageObject";s:3:"url";s:48:"[site:url]modules/custom/do_base/assets/logo.png";s:5:"width";s:3:"600";s:6:"height";s:3:"142";}' + schema_organization_name: '[site:name]' + schema_organization_same_as: 'https://github.com/drevops, https://www.linkedin.com/company/drevops, https://x.com/drev_ops' + schema_organization_type: Organization + schema_organization_url: '[site:url]' + schema_web_site_name: '[site:name]' + schema_web_site_type: WebSite + schema_web_site_url: '[site:url]' title: '[current-page:title] | [site:name]' + twitter_cards_site: '@drev_ops' + twitter_cards_type: summary_large_image diff --git a/config/default/metatag.metatag_defaults.node.yml b/config/default/metatag.metatag_defaults.node.yml index ea6effba..e4f14abb 100644 --- a/config/default/metatag.metatag_defaults.node.yml +++ b/config/default/metatag.metatag_defaults.node.yml @@ -7,6 +7,8 @@ _core: id: node label: Content tags: - title: '[node:title] | [site:name]' - description: '[node:summary]' canonical_url: '[node:url]' + description: '[node:summary]' + og_updated_time: '[node:changed:custom:c]' + og_url: '[node:url]' + title: '[node:title] | [site:name]' diff --git a/config/default/metatag.metatag_defaults.node__blog.yml b/config/default/metatag.metatag_defaults.node__blog.yml index afac4ed2..97b21645 100644 --- a/config/default/metatag.metatag_defaults.node__blog.yml +++ b/config/default/metatag.metatag_defaults.node__blog.yml @@ -5,4 +5,16 @@ dependencies: { } id: node__blog label: 'Content: Blog post' tags: + article_modified_time: '[node:changed:custom:c]' + article_published_time: '[node:created:custom:c]' description: '[node:field_c_n_summary:value]' + og_type: article + schema_article_author: 'a:5:{s:5:"@type";s:12:"Organization";s:3:"@id";s:10:"[site:url]";s:4:"name";s:11:"[site:name]";s:3:"url";s:10:"[site:url]";s:4:"logo";a:4:{s:5:"@type";s:11:"ImageObject";s:3:"url";s:48:"[site:url]modules/custom/do_base/assets/logo.png";s:5:"width";s:3:"600";s:6:"height";s:3:"142";}}' + schema_article_date_modified: '[node:changed:custom:c]' + schema_article_date_published: '[node:created:custom:c]' + schema_article_description: '[node:field_c_n_summary:value]' + schema_article_headline: '[node:title]' + schema_article_id: '[node:url]' + schema_article_main_entity_of_page: '[node:url]' + schema_article_publisher: 'a:5:{s:5:"@type";s:12:"Organization";s:3:"@id";s:10:"[site:url]";s:4:"name";s:11:"[site:name]";s:3:"url";s:10:"[site:url]";s:4:"logo";a:4:{s:5:"@type";s:11:"ImageObject";s:3:"url";s:48:"[site:url]modules/custom/do_base/assets/logo.png";s:5:"width";s:3:"600";s:6:"height";s:3:"142";}}' + schema_article_type: Article diff --git a/config/default/redirect_404.settings.yml b/config/default/redirect_404.settings.yml new file mode 100644 index 00000000..331a48c3 --- /dev/null +++ b/config/default/redirect_404.settings.yml @@ -0,0 +1,5 @@ +_core: + default_config_hash: FgPCEDevf9dhNU_QB_fo-4AWxi0rrSZ2SGfd7bLNzLM +row_limit: 10000 +pages: '' +suppress_404: false diff --git a/config/default/views.view.redirect_404.yml b/config/default/views.view.redirect_404.yml new file mode 100644 index 00000000..4207883c --- /dev/null +++ b/config/default/views.view.redirect_404.yml @@ -0,0 +1,578 @@ +uuid: 9260048c-8d44-429b-a112-1cb1be23d3f3 +langcode: en +status: true +dependencies: + module: + - redirect_404 + - user +_core: + default_config_hash: xEiwRlPcHIHzIa2IWKrMChHbyV62cH2lobBY0rHDsQA +id: redirect_404 +label: 'Redirect 404' +module: views +description: '' +tag: '' +base_table: redirect_404 +base_field: '' +display: + default: + id: default + display_title: Master + display_plugin: default + position: 0 + display_options: + title: 'Fix 404 pages' + fields: + path: + id: path + table: redirect_404 + field: path + relationship: none + group_type: group + admin_label: '' + entity_type: null + entity_field: null + plugin_id: standard + label: Path + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + count: + id: count + table: redirect_404 + field: count + relationship: none + group_type: group + admin_label: '' + plugin_id: serialized + label: Count + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + format: unserialized + key: '' + daily_count: + id: daily_count + table: redirect_404 + field: daily_count + relationship: none + group_type: group + admin_label: '' + plugin_id: serialized + label: 'Daily count' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + format: unserialized + key: '' + timestamp: + id: timestamp + table: redirect_404 + field: timestamp + relationship: none + group_type: group + admin_label: '' + plugin_id: date + label: 'Last accessed' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + date_format: short + custom_date_format: '' + timezone: '' + langcode: + id: langcode + table: redirect_404 + field: langcode + relationship: none + group_type: group + admin_label: '' + plugin_id: standard + label: Language + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + redirect_404_operations: + id: redirect_404_operations + table: redirect_404 + field: redirect_404_operations + relationship: none + group_type: group + admin_label: '' + plugin_id: redirect_404_operations + label: Operations + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + pager: + type: mini + options: + offset: 0 + items_per_page: 10 + total_pages: null + id: 0 + tags: + next: ›› + previous: ‹‹ + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + exposed_form: + type: basic + options: + submit_button: Filter + reset_button: true + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + access: + type: perm + options: + perm: 'administer redirects' + cache: + type: none + options: { } + empty: + area_text_custom: + id: area_text_custom + table: views + field: area_text_custom + relationship: none + group_type: group + admin_label: '' + plugin_id: text_custom + empty: true + content: 'There are no 404 errors to fix.' + tokenize: false + sorts: { } + arguments: { } + filters: + path: + id: path + table: redirect_404 + field: path + relationship: none + group_type: group + admin_label: '' + plugin_id: string + operator: contains + value: '' + group: 1 + exposed: true + expose: + operator_id: path_op + label: Path + description: '' + use_operator: false + operator: path_op + identifier: path + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + langcode: + id: langcode + table: redirect_404 + field: langcode + relationship: none + group_type: group + admin_label: '' + plugin_id: in_operator + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: langcode_op + label: Language + description: '' + use_operator: false + operator: langcode_op + identifier: langcode + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + reduce: false + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + resolved: + id: resolved + table: redirect_404 + field: resolved + relationship: none + group_type: group + admin_label: '' + plugin_id: boolean + operator: '=' + value: '0' + group: 1 + exposed: false + expose: + operator_id: '' + label: Resolved + description: '' + use_operator: false + operator: resolved_op + identifier: resolved + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + filter_groups: + operator: AND + groups: + 1: AND + style: + type: table + options: + grouping: { } + row_class: '' + default_row_class: true + columns: + path: path + count: count + daily_count: daily_count + timestamp: timestamp + default: count + info: + path: + sortable: true + default_sort_order: desc + align: '' + separator: '' + empty_column: false + responsive: '' + count: + sortable: true + default_sort_order: desc + align: '' + separator: '' + empty_column: false + responsive: '' + daily_count: + sortable: true + default_sort_order: desc + align: '' + separator: '' + empty_column: false + responsive: '' + timestamp: + sortable: true + default_sort_order: desc + align: '' + separator: '' + empty_column: false + responsive: '' + override: true + sticky: false + summary: '' + empty_table: false + caption: '' + description: '' + row: + type: fields + query: + type: views_query + options: + query_comment: '' + disable_sql_rewrite: false + distinct: false + replica: false + query_tags: { } + relationships: { } + header: { } + footer: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + - user.permissions + tags: { } + page_1: + id: page_1 + display_title: Page + display_plugin: page + position: 1 + display_options: + enabled: true + display_description: 'Lists 404 error paths with no redirect assigned yet.' + display_extenders: { } + path: admin/config/search/redirect/404 + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + - user.permissions + tags: { } diff --git a/docs/README.md b/docs/README.md index 7bda973d..05f215a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,4 +11,5 @@ applies to this project. For **how** to perform operations, see - [Deployment](deployment.md) - Deployment configuration - [Releasing](releasing.md) - Release process and versioning - [Sitemap](sitemap.md) - XML sitemap module, coverage and generation +- [SEO](seo.md) - Meta tags, social share cards and structured data - [FAQs](faqs.md) - Project-specific FAQs diff --git a/docs/seo.md b/docs/seo.md new file mode 100644 index 00000000..3a9260c6 --- /dev/null +++ b/docs/seo.md @@ -0,0 +1,90 @@ +# SEO and social sharing + +This document describes the meta tags, share cards and structured data the site publishes. The XML sitemap is covered separately in [Sitemap](sitemap.md). + +## Modules + +| Module | Role | +|---|---| +| `metatag` | Meta tag defaults per entity type and bundle, plus a per-node override field (`field_n_metatags`) | +| `metatag_open_graph` | `og:*` tags read by Facebook, LinkedIn and Slack | +| `metatag_twitter_cards` | `twitter:*` tags read by X | +| `metatag_dc` | Dublin Core tags | +| `schema_metatag` | Renders JSON-LD structured data through the same metatag defaults | +| `schema_organization`, `schema_web_site`, `schema_article` | The three Schema.org types this site publishes | +| `pathauto` | Readable, stable URL aliases | +| `redirect` | Keeps old URLs working after an alias changes | +| `redirect_404` | Records 404 paths at `/admin/config/search/redirect/404` so real lost traffic can be turned into redirects | +| `robotstxt` | Serves `/robots.txt` from configuration | +| `xmlsitemap` | See [Sitemap](sitemap.md) | + +Deliberately not installed: `yoast_seo` (a heavy analyser on every node form), `seo_checklist` (an admin checklist with no runtime effect), `linkchecker` (a crawler with ongoing cron cost) and `metatag_hreflang` (meaningless while the site is single-language). + +## What every page publishes + +- `title`, `description` and `canonical_url`, from the metatag defaults. +- The full Open Graph set: `og:site_name`, `og:type`, `og:url`, `og:title`, `og:description`, `og:image` with `og:image:width`, `og:image:height` and `og:image:alt`. +- The full Twitter Card set: `twitter:card`, `twitter:site`, `twitter:title`, `twitter:description`, `twitter:image` and `twitter:image:alt`. +- A JSON-LD `@graph` carrying `Organization` and `WebSite`, plus `Article` on blog posts. + +`twitter:card` is `summary_large_image` site-wide. Any other value makes X render a small square thumbnail, which is why the share image is produced at 1200x630. + +## Where each value comes from + +Configuration holds the values that genuinely differ between pages: + +| Metatag default | Holds | +|---|---| +| `global` | Site-level values, the Twitter card type and handle, and the `Organization` and `WebSite` structured data | +| `front` | The front page's canonical and Open Graph URL | +| `node` | Node title, description and URL | +| `node__blog` | `og:type: article`, the article timestamps and the `Article` structured data | +| `node__civictheme_page`, `node__project` | The summary field the description is taken from | + +The social **title and description are not configured**. `MetatagsAlterHook` derives them from the `title` and `description` tags, and the Twitter pair from the Open Graph pair. This keeps one source of truth for the wording, and it is also the only way those tags reach the front page: `metatag_get_default_tags()` treats the front page, 403 and 404 as special pages and stops after the global and special defaults, never reading the entity or bundle defaults. + +The social **image is not configured either**, because a metatag default that resolves to nothing is dropped rather than falling back to its parent, and most pages have no thumbnail. `MetatagsAlterHook` resolves it instead: + +1. The node's `field_c_n_thumbnail` media, rendered through the `social_share` image style (1200x630, focal point aware), with the media's alt text. +2. Otherwise `web/modules/custom/do_base/assets/social-share.jpg`, with the site name as alt text. + +An editor who sets `og:image` by hand on a node keeps it: the hook leaves the whole image family alone in that case, and emits no width, height or alt, because it cannot know them for a file it did not choose. + +A thumbnail is passed over in favour of the fallback when no image toolkit can derive it (the image field accepts SVG) or when the file is recorded in the database but absent from the environment. + +The `Article` image in the structured data is filled from the same resolved value, so it carries the same fallbacks. It is set as an `ImageObject` rather than a bare URL because `SchemaImageObjectBase::output()` drops any value without a `url` key. + +## Image assets + +Both live in `web/modules/custom/do_base/assets/` and are generated from the theme's brand assets: + +- `social-share.jpg` (1200x630) - the fallback share card: the brand wordmark over the dark navy page background, under the site's positioning line with its accent phrase picked out in coral. It is rendered from `.artifacts/tmp/social-card.html`, which carries the same colour tokens, Lexend weights and letter spacing as the branding styleguide, so the card matches the rest of the brand rather than approximating it. +- `logo.png` (600x142) - the `Organization` logo in the structured data. Schema.org requires a raster image, and the brand logo exists only as SVG. + +Replacing either file is the whole change if a designed asset arrives later. The dimensions of `social-share.jpg` are stated in `MetatagsAlterHook::fallbackImage()` rather than measured, so they must be kept in step with the file. + +## Known limits + +- `Article.author` is the organisation rather than a person. The site has no per-author profiles to point at. +- `twitter:creator` is unset for the same reason. +- No `robots` meta tag default is set. Indexing is governed by `/robots.txt`, and `do_base` adds `noindex, nofollow` to preview link pages only. + +## Why publication dates come from `created` + +`article:published_time` and `Article.datePublished` are taken from the node's `created` timestamp, not from the moment a draft was first published. That is deliberate: `created` is the editorially-controlled "Authored on" date, and it is the field `views.view.civictheme_automated_list` sorts the blog by, so it is already the date the site presents as a post's date. Structured data is expected to agree with what a visitor sees, and a separate first-publication timestamp would disagree with the visible ordering. + +Changing this would mean adding a field populated on the first published transition and backfilling existing posts - a content-modelling change, not an SEO one. + +## Verifying a change + +```bash +ahoy test-bdd -- --tags=@metatags +ahoy test-functional -- --filter=SocialCardTest +``` + +The Behat feature asserts the rendered tags against the real configuration and theme; the PHPUnit test covers the resolver's fallbacks, which are the paths that fail silently in production. External validators worth a look after a change that touches structured data: the [Schema Markup Validator](https://validator.schema.org/) and [Google's Rich Results Test](https://search.google.com/test/rich-results). + +## Related + +- [Sitemap](sitemap.md) - XML sitemap coverage and generation +- [Development](development.md) - function visibility conventions the hook follows diff --git a/tests/behat/features/metatags.feature b/tests/behat/features/metatags.feature index eef949d0..d32c09e4 100644 --- a/tests/behat/features/metatags.feature +++ b/tests/behat/features/metatags.feature @@ -14,3 +14,70 @@ Feature: Page content metatags Then the response should contain "Test Metatags Page | " And the response should contain "<meta name=\"description\" content=\"This is a test summary for metatags testing\"" And the response should contain "<link rel=\"canonical\" href=\"" + + @api + Scenario: Page without a thumbnail is shared with the site-wide card image + Given the following civictheme_page content: + | title | status | field_c_n_summary | + | [TEST] Social Card Page | 1 | [TEST] A summary shown on a share card | + When I visit the "civictheme_page" content page with the title "[TEST] Social Card Page" + Then the response should contain "<meta property=\"og:site_name\" content=\"DrevOps\"" + And the response should contain "<meta property=\"og:type\" content=\"website\"" + And the response should contain "<meta property=\"og:title\" content=\"[TEST] Social Card Page | DrevOps\"" + And the response should contain "<meta property=\"og:description\" content=\"[TEST] A summary shown on a share card\"" + And the response should contain "/modules/custom/do_base/assets/social-share.jpg\"" + # Both networks reject a root-relative image URL outright. + And the response should not contain "content=\"/modules/custom/do_base/assets/social-share.jpg\"" + And the response should contain "<meta property=\"og:image:width\" content=\"1200\"" + And the response should contain "<meta property=\"og:image:height\" content=\"630\"" + # Without this card type X renders a small square thumbnail instead of the + # wide image, which is the whole point of supplying a 1200x630 asset. + And the response should contain "<meta name=\"twitter:card\" content=\"summary_large_image\"" + And the response should contain "<meta name=\"twitter:site\" content=\"@drev_ops\"" + And the response should contain "<meta name=\"twitter:title\" content=\"[TEST] Social Card Page | DrevOps\"" + And the response should contain "<meta name=\"twitter:description\" content=\"[TEST] A summary shown on a share card\"" + And the response should contain "<meta name=\"twitter:image\" content=\"" + + @api + Scenario: Page with a thumbnail is shared with that image, sized for the card + Given the following managed files: + | path | uri | status | + | image.jpg | public://do_test/image.jpg | 1 | + And the following media "civictheme_image" exist: + | name | field_c_m_image | + | [TEST] Social Card Image | image.jpg | + And the following civictheme_page content: + | title | status | field_c_n_thumbnail | + | [TEST] Social Card Thumbnail Page | 1 | [TEST] Social Card Image | + When I visit the "civictheme_page" content page with the title "[TEST] Social Card Thumbnail Page" + Then the response should contain "/styles/social_share/" + And the response should not contain "/modules/custom/do_base/assets/social-share.jpg" + + @api + Scenario: Front page is shared with a complete card + Given I am an anonymous user + When I am on the homepage + Then the response should contain "<meta property=\"og:image\" content=\"" + And the response should contain "<meta property=\"og:description\" content=\"" + And the response should contain "<meta name=\"twitter:card\" content=\"summary_large_image\"" + And the response should contain "<meta name=\"twitter:image\" content=\"" + + @api @blog + Scenario: Blog post is marked up as an article for search engines + Given the following blog content: + | title | status | field_c_n_summary | field_c_n_banner_type | field_c_n_banner_theme | field_c_n_banner_blend_mode | field_c_n_vertical_spacing | + | [TEST] Social Card Post | 1 | [TEST] A summary of post | large | inherit | normal | both | + When I visit the "blog" content page with the title "[TEST] Social Card Post" + Then the response should contain "<meta property=\"og:type\" content=\"article\"" + And the response should contain "<meta property=\"article:published_time\" content=\"" + And the response should contain "<script type=\"application/ld+json\">" + And the response should contain "\"@type\": \"Article\"" + And the response should contain "\"@type\": \"Organization\"" + And the response should contain "\"@type\": \"WebSite\"" + And the response should contain "\"headline\": \"[TEST] Social Card Post\"" + # The post carries no thumbnail, so this also proves the structured data + # image shares the resolver's fallback rather than being dropped. + And the response should contain "/modules/custom/do_base/assets/social-share.jpg" + # Only the Open Graph and Twitter tags get a host prepended for them, so + # the structured data image has to be absolute before it is ever set. + And the response should not contain "\"url\": \"/modules/custom/do_base/assets/social-share.jpg\"" diff --git a/web/modules/custom/do_base/assets/logo.png b/web/modules/custom/do_base/assets/logo.png new file mode 100644 index 00000000..af80ccdc Binary files /dev/null and b/web/modules/custom/do_base/assets/logo.png differ diff --git a/web/modules/custom/do_base/assets/social-share.jpg b/web/modules/custom/do_base/assets/social-share.jpg new file mode 100644 index 00000000..1b85b452 Binary files /dev/null and b/web/modules/custom/do_base/assets/social-share.jpg differ diff --git a/web/modules/custom/do_base/src/Hook/MetatagsAlterHook.php b/web/modules/custom/do_base/src/Hook/MetatagsAlterHook.php new file mode 100644 index 00000000..f3bcf6f7 --- /dev/null +++ b/web/modules/custom/do_base/src/Hook/MetatagsAlterHook.php @@ -0,0 +1,183 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\do_base\Hook; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Url; +use Drupal\file\FileInterface; +use Drupal\image\ImageStyleInterface; +use Drupal\image\Plugin\Field\FieldType\ImageItem; +use Drupal\media\MediaInterface; +use Drupal\schema_metatag\SchemaMetatagManager; + +/** + * Fills in the image and text a page carries when it is shared. + */ +final class MetatagsAlterHook { + + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected ConfigFactoryInterface $configFactory, + protected ModuleExtensionList $moduleExtensionList, + ) {} + + /** + * Implements hook_metatags_alter(). + */ + #[Hook('metatags_alter')] + public function alter(array &$metatags, array &$context): void { + $entity = $context['entity'] ?? NULL; + + $this->mirrorSocialText($metatags); + $this->setSocialImage($metatags, $entity instanceof ContentEntityInterface ? $entity : NULL); + } + + /** + * Gives the social tags the page title and description when they have none. + */ + protected function mirrorSocialText(array &$metatags): void { + // Metatag reads the entity and bundle defaults only on an ordinary entity + // route: on the front page, 403 and 404 it stops after the global and + // special defaults, so a description configured per bundle never reaches + // those pages. Deriving the social text from the tags that are always + // resolved covers them, and leaves one place to change the wording. + $sources = [ + 'og_title' => 'title', + 'og_description' => 'description', + 'twitter_cards_title' => 'og_title', + 'twitter_cards_description' => 'og_description', + ]; + + foreach ($sources as $tag => $source) { + if (empty($metatags[$tag]) && !empty($metatags[$source])) { + $metatags[$tag] = $metatags[$source]; + } + } + } + + /** + * Gives the social tags the image that represents the page. + */ + protected function setSocialImage(array &$metatags, ?ContentEntityInterface $entity): void { + // An image already in the set was chosen by hand on the entity. Its + // dimensions and alt text describe a file this code never saw, so neither + // is asserted alongside it. + if (empty($metatags['og_image'])) { + $image = $this->socialImage($entity); + + $metatags['og_image'] = $image['url']; + $metatags['og_image_width'] = (string) $image['width']; + $metatags['og_image_height'] = (string) $image['height']; + $metatags['og_image_alt'] = $image['alt']; + } + + if (empty($metatags['twitter_cards_image'])) { + $metatags['twitter_cards_image'] = $metatags['og_image']; + + if (!empty($metatags['og_image_alt'])) { + $metatags['twitter_cards_image_alt'] = $metatags['og_image_alt']; + } + } + + // Structured data takes the same image, so an article that fell back to + // the site-wide one is not left describing an image it does not have. The + // type guard keeps this to pages already carrying Article markup, which is + // the only group the tag belongs to. + if (!empty($metatags['schema_article_type']) && empty($metatags['schema_article_image']) && class_exists(SchemaMetatagManager::class)) { + $metatags['schema_article_image'] = SchemaMetatagManager::serialize([ + '@type' => 'ImageObject', + 'representativeOfPage' => 'True', + 'url' => $metatags['og_image'], + ]); + } + } + + /** + * Resolves the image that represents a page when it is shared. + * + * @return array{url: string, alt: string, width: int, height: int} + * The image URL, its alt text and the dimensions it is served at. + */ + protected function socialImage(?ContentEntityInterface $entity): array { + return ($entity instanceof ContentEntityInterface ? $this->socialImageFromThumbnail($entity) : NULL) ?? $this->fallbackImage(); + } + + /** + * Derives a share image from an entity's thumbnail. + * + * @return array{url: string, alt: string, width: int, height: int}|null + * The image, or NULL when the entity has no thumbnail that can be derived + * into one. + */ + protected function socialImageFromThumbnail(ContentEntityInterface $entity): ?array { + if (!$entity->hasField('field_c_n_thumbnail')) { + return NULL; + } + + $media = $entity->get('field_c_n_thumbnail')->entity; + + if (!$media instanceof MediaInterface || !$media->hasField('field_c_m_image')) { + return NULL; + } + + $item = $media->get('field_c_m_image')->first(); + + if (!$item instanceof ImageItem || !$item->entity instanceof FileInterface) { + return NULL; + } + + $style = $this->entityTypeManager->getStorage('image_style')->load('social_share'); + $uri = $item->entity->getFileUri(); + + // The image field accepts SVG, which no image toolkit can derive, and a + // referenced file can be absent on an environment that carries the database + // but not the files. Either way the fallback image is a better share card + // than a URL that resolves to nothing. + if (!$style instanceof ImageStyleInterface || !$style->supportsUri($uri) || !file_exists($uri)) { + return NULL; + } + + $values = $item->getValue(); + + // Reading the dimensions back from the style keeps them true to whatever + // the effects actually do, so the tags cannot drift from the style config. + $dimensions = ['width' => $values['width'] ?? NULL, 'height' => $values['height'] ?? NULL]; + $style->transformDimensions($dimensions, $uri); + + return [ + 'url' => $style->buildUrl($uri), + 'alt' => (string) ($values['alt'] ?? ''), + 'width' => (int) $dimensions['width'], + 'height' => (int) $dimensions['height'], + ]; + } + + /** + * Builds the site-wide share image. + * + * @return array{url: string, alt: string, width: int, height: int} + * The image. + */ + protected function fallbackImage(): array { + $path = $this->moduleExtensionList->getPath('do_base'); + + // Absolute, because the structured data tags render their value verbatim: + // only the Open Graph and Twitter tags declare 'absolute_url' and get a + // host prepended for them, and schema.org will not accept a relative URL. + // The dimensions are stated rather than measured, so they must be kept in + // step with the file itself if it is ever replaced. + return [ + 'url' => Url::fromUri('base:' . $path . '/assets/social-share.jpg', ['absolute' => TRUE])->toString(), + 'alt' => (string) $this->configFactory->get('system.site')->get('name'), + 'width' => 1200, + 'height' => 630, + ]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Functional/SocialCardTest.php b/web/modules/custom/do_base/tests/src/Functional/SocialCardTest.php new file mode 100644 index 00000000..93a828b2 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Functional/SocialCardTest.php @@ -0,0 +1,381 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\do_base\Functional; + +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\Entity\File; +use Drupal\file\FileInterface; +use Drupal\image\Entity\ImageStyle; +use Drupal\media\Entity\Media; +use Drupal\media\Entity\MediaType; +use Drupal\media\MediaInterface; +use Drupal\node\NodeInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests the image and text a page carries when it is shared. + * + * The cases that matter are the ones where the page has nothing to offer: a + * thumbnail is optional, most pages have none, and those are the URLs that get + * shared most. A card that silently loses its image is the failure this covers. + */ +#[Group('do_base')] +class SocialCardTest extends DoBaseFunctionalTestBase { + + /** + * Machine name of the media type holding shareable images. + */ + protected const MEDIA_TYPE = 'civictheme_image'; + + /** + * Site name asserted against, in place of the random installer default. + */ + protected const SITE_NAME = '[TEST] DrevOps'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'node', + 'field', + 'file', + 'image', + 'media', + 'metatag', + 'metatag_open_graph', + 'metatag_twitter_cards', + 'do_base', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->config('system.site')->set('name', static::SITE_NAME)->save(); + + // The site uses a focal point effect, which needs a module this test does + // not install. Only the resulting dimensions matter here, so a core effect + // producing the same size stands in for it. + $style = ImageStyle::create(['name' => 'social_share', 'label' => 'Social share']); + $style->addImageEffect([ + 'id' => 'image_scale_and_crop', + 'data' => ['width' => 1200, 'height' => 630], + ]); + $style->save(); + + $this->drupalCreateContentType(['type' => 'page', 'name' => 'Page']); + $this->createImageMediaType(); + $this->createThumbnailField(); + $this->createMetatagField(); + } + + /** + * Tests that a thumbnail becomes the card image, sized by the image style. + */ + public function testThumbnailBecomesTheCardImage(): void { + // Prepare. + $media = $this->createImageMedia($this->createImageFile(), '[TEST] A thumbnail'); + $node = $this->createPage('[TEST] Page With Thumbnail', $media); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $url = $this->metatagContent('og:image'); + $this->assertStringContainsString('/styles/social_share/', $url); + $this->assertMatchesRegularExpression('#^https?://#', $url, 'Both networks reject a relative image URL.'); + $this->assertSame($url, $this->metatagContent('twitter:image')); + $this->assertSame('1200', $this->metatagContent('og:image:width')); + $this->assertSame('630', $this->metatagContent('og:image:height')); + $this->assertSame('[TEST] A thumbnail', $this->metatagContent('og:image:alt')); + $this->assertSame('[TEST] A thumbnail', $this->metatagContent('twitter:image:alt')); + } + + /** + * Tests that a page with no thumbnail still carries an image. + */ + public function testPageWithoutThumbnailFallsBackToTheSiteImage(): void { + // Prepare. + $node = $this->createPage('[TEST] Page Without Thumbnail'); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertFallbackImage(); + } + + /** + * Tests that a thumbnail no image style can serve is passed over. + * + * The image field accepts SVG, and a file recorded in the database can be + * absent from an environment that never received it. Either would otherwise + * produce a derivative URL that resolves to nothing. + */ + #[DataProvider('dataProviderUnusableThumbnailFallsBackToTheSiteImage')] + public function testUnusableThumbnailFallsBackToTheSiteImage(string $extension, bool $write_file): void { + // Prepare. + $media = $this->createImageMedia($this->createImageFile($extension, $write_file), '[TEST] An unusable thumbnail'); + $node = $this->createPage('[TEST] Page With Unusable Thumbnail', $media); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertFallbackImage(); + } + + /** + * Data provider for testUnusableThumbnailFallsBackToTheSiteImage. + */ + public static function dataProviderUnusableThumbnailFallsBackToTheSiteImage(): \Iterator { + yield 'no image toolkit can derive an svg' => ['svg', TRUE]; + yield 'the file is recorded but not present' => ['png', FALSE]; + } + + /** + * Tests that a page which is not an entity still carries an image. + */ + public function testPageWithoutAnEntityFallsBackToTheSiteImage(): void { + // Act. + $this->drupalGet('user/login'); + + // Assert. + $this->assertFallbackImage(); + } + + /** + * Tests that an image chosen by hand on the entity is left alone. + * + * Its dimensions and alt text are unknown here, so asserting either would + * describe the wrong file. + */ + public function testImageSetByHandIsLeftAlone(): void { + // Prepare. + $chosen = 'https://example.com/chosen.png'; + $node = $this->createPage('[TEST] Page With Chosen Image'); + $node->set('field_n_metatags', json_encode(['og_image' => $chosen]))->save(); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSame($chosen, $this->metatagContent('og:image')); + $this->assertSame($chosen, $this->metatagContent('twitter:image'), 'The choice is expected to carry over to the tag that has none.'); + $this->assertNull($this->getSession()->getPage()->find('css', 'meta[property="og:image:width"]')); + $this->assertNull($this->getSession()->getPage()->find('css', 'meta[property="og:image:height"]')); + $this->assertNull($this->getSession()->getPage()->find('css', 'meta[property="og:image:alt"]')); + $this->assertNull($this->getSession()->getPage()->find('css', 'meta[name="twitter:image:alt"]')); + } + + /** + * Tests that an image chosen for X alone still leaves Open Graph an image. + * + * The metatag field exposes both image tags, so either can be set without + * the other. The tag that was not set still needs an image of its own. + */ + public function testImageChosenForTwitterAloneLeavesOpenGraphResolved(): void { + // Prepare. + $chosen = 'https://example.com/chosen.png'; + $node = $this->createPage('[TEST] Page With Chosen Twitter Image'); + $node->set('field_n_metatags', json_encode(['twitter_cards_image' => $chosen]))->save(); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSame($chosen, $this->metatagContent('twitter:image')); + $this->assertStringEndsWith('/modules/custom/do_base/assets/social-share.jpg', $this->metatagContent('og:image')); + } + + /** + * Tests that the social tags take the page's own title and description. + */ + public function testSocialTextMirrorsTheTitleAndDescription(): void { + // Prepare. + $node = $this->createPage('[TEST] Page With Text'); + $node->set('field_n_metatags', json_encode([ + 'title' => '[TEST] A title', + 'description' => '[TEST] A description', + ]))->save(); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSame('[TEST] A title', $this->metatagContent('og:title')); + $this->assertSame('[TEST] A title', $this->metatagContent('twitter:title')); + $this->assertSame('[TEST] A description', $this->metatagContent('og:description')); + $this->assertSame('[TEST] A description', $this->metatagContent('twitter:description')); + } + + /** + * Tests that social text chosen by hand is left alone. + */ + public function testSocialTextSetByHandIsLeftAlone(): void { + // Prepare. + $node = $this->createPage('[TEST] Page With Chosen Text'); + $node->set('field_n_metatags', json_encode([ + 'description' => '[TEST] A description', + 'og_description' => '[TEST] A social description', + ]))->save(); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSame('[TEST] A social description', $this->metatagContent('og:description')); + $this->assertSame('[TEST] A social description', $this->metatagContent('twitter:description')); + } + + /** + * Asserts the page carries the site-wide image rather than one of its own. + */ + protected function assertFallbackImage(): void { + $url = $this->metatagContent('og:image'); + + $this->assertStringEndsWith('/modules/custom/do_base/assets/social-share.jpg', $url); + $this->assertMatchesRegularExpression('#^https?://#', $url, 'Both networks reject a relative image URL.'); + $this->assertSame($url, $this->metatagContent('twitter:image')); + $this->assertSame('1200', $this->metatagContent('og:image:width')); + $this->assertSame('630', $this->metatagContent('og:image:height')); + $this->assertSame(static::SITE_NAME, $this->metatagContent('og:image:alt')); + $this->assertSame(static::SITE_NAME, $this->metatagContent('twitter:image:alt')); + } + + /** + * Returns the content of a meta tag on the page currently loaded. + * + * Open Graph uses 'property' and Twitter uses 'name', so both are tried. + */ + protected function metatagContent(string $name): string { + $page = $this->getSession()->getPage(); + $element = $page->find('css', sprintf('meta[property="%s"]', $name)) ?? $page->find('css', sprintf('meta[name="%s"]', $name)); + + $this->assertNotNull($element, sprintf('Expected a "%s" meta tag on the page.', $name)); + + return (string) $element->getAttribute('content'); + } + + /** + * Creates a page, optionally carrying a thumbnail. + */ + protected function createPage(string $title, ?MediaInterface $thumbnail = NULL): NodeInterface { + return $this->drupalCreateNode([ + 'type' => 'page', + 'title' => $title, + 'field_c_n_thumbnail' => $thumbnail instanceof MediaInterface ? ['target_id' => $thumbnail->id()] : NULL, + ]); + } + + /** + * Creates a media item wrapping an image file. + */ + protected function createImageMedia(FileInterface $file, string $alt): MediaInterface { + $media = Media::create([ + 'bundle' => static::MEDIA_TYPE, + 'name' => '[TEST] Media ' . $this->randomMachineName(), + 'field_c_m_image' => ['target_id' => $file->id(), 'alt' => $alt], + ]); + $media->save(); + + return $media; + } + + /** + * Creates a file entity, optionally without writing the file itself. + */ + protected function createImageFile(string $extension = 'png', bool $write_file = TRUE): FileInterface { + $uri = 'public://' . $this->randomMachineName() . '.' . $extension; + + if ($write_file) { + $contents = $extension === 'svg' + ? '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"></svg>' + : (string) file_get_contents($this->root . '/core/tests/fixtures/files/image-1.png'); + + file_put_contents($uri, $contents); + } + + $file = File::create(['uri' => $uri]); + $file->setPermanent(); + $file->save(); + + return $file; + } + + /** + * Creates the media type shareable images live in. + */ + protected function createImageMediaType(): void { + $media_type = MediaType::create([ + 'id' => static::MEDIA_TYPE, + 'label' => 'Image', + 'source' => 'image', + ]); + $media_type->save(); + + FieldStorageConfig::create([ + 'entity_type' => 'media', + 'field_name' => 'field_c_m_image', + 'type' => 'image', + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'media', + 'bundle' => static::MEDIA_TYPE, + 'field_name' => 'field_c_m_image', + 'label' => 'Image', + 'settings' => ['alt_field' => TRUE, 'file_extensions' => 'png jpg svg'], + ])->save(); + + $media_type->set('source_configuration', ['source_field' => 'field_c_m_image'])->save(); + } + + /** + * Adds the thumbnail field pages carry. + */ + protected function createThumbnailField(): void { + FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_c_n_thumbnail', + 'type' => 'entity_reference', + 'settings' => ['target_type' => 'media'], + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'node', + 'bundle' => 'page', + 'field_name' => 'field_c_n_thumbnail', + 'label' => 'Thumbnail', + 'settings' => [ + 'handler' => 'default:media', + 'handler_settings' => ['target_bundles' => [static::MEDIA_TYPE => static::MEDIA_TYPE]], + ], + ])->save(); + } + + /** + * Adds the field an editor overrides meta tags through. + */ + protected function createMetatagField(): void { + FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_n_metatags', + 'type' => 'metatag', + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'node', + 'bundle' => 'page', + 'field_name' => 'field_n_metatags', + 'label' => 'Meta tags', + ])->save(); + } + +} diff --git a/web/modules/custom/do_base/tests/src/Kernel/MetatagsAlterHookTest.php b/web/modules/custom/do_base/tests/src/Kernel/MetatagsAlterHookTest.php new file mode 100644 index 00000000..e26bcb63 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Kernel/MetatagsAlterHookTest.php @@ -0,0 +1,480 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\do_base\Kernel; + +use Drupal\do_base\Hook\MetatagsAlterHook; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\Entity\File; +use Drupal\file\FileInterface; +use Drupal\image\Entity\ImageStyle; +use Drupal\media\Entity\Media; +use Drupal\media\Entity\MediaType; +use Drupal\media\MediaInterface; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests the image and text resolved for a page's share card. + * + * The hook is driven directly rather than through a rendered page, so each + * branch of the resolver is reachable on its own. The rendered end of this + * lives in the Behat feature. + */ +#[Group('do_base')] +class MetatagsAlterHookTest extends DoBaseKernelTestBase { + + /** + * Machine name of the media type holding shareable images. + */ + protected const MEDIA_TYPE = 'civictheme_image'; + + /** + * Site name asserted against. + */ + protected const SITE_NAME = '[TEST] DrevOps'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'field', + 'file', + 'image', + 'media', + 'node', + 'do_base', + ]; + + /** + * The hook under test. + */ + protected MetatagsAlterHook $hook; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installEntitySchema('media'); + $this->installEntitySchema('node'); + $this->installSchema('file', ['file_usage']); + $this->installConfig(['field', 'system', 'image', 'media', 'node']); + + $this->config('system.site')->set('name', static::SITE_NAME)->save(); + + // The site uses a focal point effect, which needs a module this test does + // not install. Only the resulting dimensions matter here, so a core effect + // producing the same size stands in for it. + $style = ImageStyle::create(['name' => 'social_share', 'label' => 'Social share']); + $style->addImageEffect([ + 'id' => 'image_scale_and_crop', + 'data' => ['width' => 1200, 'height' => 630], + ]); + $style->save(); + + NodeType::create(['type' => 'page', 'name' => 'Page'])->save(); + $this->createImageMediaType(); + $this->createThumbnailField(); + + $this->hook = new MetatagsAlterHook( + $this->container->get('entity_type.manager'), + $this->container->get('config.factory'), + $this->container->get('extension.list.module'), + ); + } + + /** + * Tests that a page with nothing of its own still carries an image. + */ + public function testPageWithNothingOfItsOwnGetsTheSiteImage(): void { + // Prepare. + $metatags = []; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertFallbackImage($metatags); + } + + /** + * Tests that a thumbnail becomes the card image, sized by the image style. + */ + public function testThumbnailBecomesTheCardImage(): void { + // Prepare. + $media = $this->createImageMedia($this->createImageFile(), '[TEST] A thumbnail'); + $metatags = []; + + // Act. + $this->alter($metatags, $this->createPage($media)); + + // Assert. + $this->assertStringContainsString('/styles/social_share/', $metatags['og_image']); + $this->assertMatchesRegularExpression('#^https?://#', $metatags['og_image']); + $this->assertSame($metatags['og_image'], $metatags['twitter_cards_image']); + $this->assertSame('1200', $metatags['og_image_width']); + $this->assertSame('630', $metatags['og_image_height']); + $this->assertSame('[TEST] A thumbnail', $metatags['og_image_alt']); + $this->assertSame('[TEST] A thumbnail', $metatags['twitter_cards_image_alt']); + } + + /** + * Tests that a thumbnail no image style can serve is passed over. + */ + #[DataProvider('dataProviderUnusableThumbnailIsPassedOver')] + public function testUnusableThumbnailIsPassedOver(string $extension, bool $write_file): void { + // Prepare. + $media = $this->createImageMedia($this->createImageFile($extension, $write_file), '[TEST] Unusable'); + $metatags = []; + + // Act. + $this->alter($metatags, $this->createPage($media)); + + // Assert. + $this->assertFallbackImage($metatags); + } + + /** + * Data provider for testUnusableThumbnailIsPassedOver. + */ + public static function dataProviderUnusableThumbnailIsPassedOver(): \Iterator { + yield 'no image toolkit can derive an svg' => ['svg', TRUE]; + yield 'the file is recorded but not present' => ['png', FALSE]; + } + + /** + * Tests that an entity with no thumbnail to offer falls back. + */ + #[DataProvider('dataProviderEntityWithoutThumbnailFallsBack')] + public function testEntityWithoutThumbnailFallsBack(bool $with_field): void { + // Prepare. + $metatags = []; + $node = $with_field ? $this->createPage() : $this->createNodeWithoutThumbnailField(); + + // Act. + $this->alter($metatags, $node); + + // Assert. + $this->assertFallbackImage($metatags); + } + + /** + * Data provider for testEntityWithoutThumbnailFallsBack. + */ + public static function dataProviderEntityWithoutThumbnailFallsBack(): \Iterator { + yield 'the field exists but is empty' => [TRUE]; + yield 'the bundle has no thumbnail field' => [FALSE]; + } + + /** + * Tests that a thumbnail holding no image at all is passed over. + * + * The image field is required on the media form, so only an item created + * around that form reaches this state, but the resolver still meets it. + */ + public function testThumbnailWithoutAnImageIsPassedOver(): void { + // Prepare. + $media = Media::create(['bundle' => static::MEDIA_TYPE, 'name' => '[TEST] Empty Media']); + $media->save(); + $metatags = []; + + // Act. + $this->alter($metatags, $this->createPage($media)); + + // Assert. + $this->assertFallbackImage($metatags); + } + + /** + * Tests that an image chosen by hand on the entity is left alone. + */ + public function testImageChosenByHandIsLeftAlone(): void { + // Prepare. + $chosen = 'https://example.com/chosen.png'; + $metatags = ['og_image' => $chosen]; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertSame($chosen, $metatags['og_image']); + $this->assertSame($chosen, $metatags['twitter_cards_image'], 'The choice is expected to carry over to the tag that has none.'); + $this->assertArrayNotHasKey('og_image_width', $metatags); + $this->assertArrayNotHasKey('og_image_height', $metatags); + $this->assertArrayNotHasKey('og_image_alt', $metatags); + $this->assertArrayNotHasKey('twitter_cards_image_alt', $metatags); + } + + /** + * Tests that an image chosen for X alone leaves Open Graph one of its own. + */ + public function testImageChosenForTwitterAloneLeavesOpenGraphResolved(): void { + // Prepare. + $chosen = 'https://example.com/chosen.png'; + $metatags = ['twitter_cards_image' => $chosen]; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertSame($chosen, $metatags['twitter_cards_image']); + $this->assertStringEndsWith('/assets/social-share.jpg', $metatags['og_image']); + $this->assertArrayNotHasKey('twitter_cards_image_alt', $metatags); + } + + /** + * Tests that the social tags take the page's own title and description. + */ + public function testSocialTextIsTakenFromTheTitleAndDescription(): void { + // Prepare. + $metatags = ['title' => '[TEST] A title', 'description' => '[TEST] A description']; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertSame('[TEST] A title', $metatags['og_title']); + $this->assertSame('[TEST] A title', $metatags['twitter_cards_title']); + $this->assertSame('[TEST] A description', $metatags['og_description']); + $this->assertSame('[TEST] A description', $metatags['twitter_cards_description']); + } + + /** + * Tests that social text chosen by hand is left alone. + */ + #[DataProvider('dataProviderSocialTextChosenByHandIsLeftAlone')] + public function testSocialTextChosenByHandIsLeftAlone(string $tag, string $source): void { + // Prepare. + $metatags = [$source => '[TEST] Derived', $tag => '[TEST] Chosen']; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertSame('[TEST] Chosen', $metatags[$tag]); + } + + /** + * Data provider for testSocialTextChosenByHandIsLeftAlone. + */ + public static function dataProviderSocialTextChosenByHandIsLeftAlone(): \Iterator { + yield 'open graph title' => ['og_title', 'title']; + yield 'open graph description' => ['og_description', 'description']; + yield 'twitter title' => ['twitter_cards_title', 'og_title']; + yield 'twitter description' => ['twitter_cards_description', 'og_description']; + } + + /** + * Tests that a page missing a title or description gets no social text. + * + * An empty tag is dropped rather than rendered, so nothing is gained by + * inventing a value here. + */ + public function testSocialTextIsNotInventedWhenThereIsNone(): void { + // Prepare. + $metatags = []; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertArrayNotHasKey('og_title', $metatags); + $this->assertArrayNotHasKey('og_description', $metatags); + $this->assertArrayNotHasKey('twitter_cards_title', $metatags); + $this->assertArrayNotHasKey('twitter_cards_description', $metatags); + } + + /** + * Tests that an article's structured data carries the resolved image. + * + * Only the Open Graph and Twitter tags get a host prepended for them, so an + * absolute URL has to arrive here already built. + */ + public function testArticleStructuredDataTakesTheSameImage(): void { + // Prepare. + $metatags = ['schema_article_type' => 'Article']; + + // Act. + $this->alter($metatags); + + // Assert. + $image = unserialize($metatags['schema_article_image'], ['allowed_classes' => FALSE]); + $this->assertIsArray($image); + $this->assertSame('ImageObject', $image['@type']); + $this->assertSame($metatags['og_image'], $image['url']); + $this->assertMatchesRegularExpression('#^https?://#', $image['url']); + } + + /** + * Tests that a page carrying no article markup gets no article image. + */ + public function testPageWithoutArticleMarkupGetsNoArticleImage(): void { + // Prepare. + $metatags = []; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertArrayNotHasKey('schema_article_image', $metatags); + } + + /** + * Tests that an article image chosen by hand is left alone. + */ + public function testArticleImageChosenByHandIsLeftAlone(): void { + // Prepare. + $metatags = ['schema_article_type' => 'Article', 'schema_article_image' => 'chosen']; + + // Act. + $this->alter($metatags); + + // Assert. + $this->assertSame('chosen', $metatags['schema_article_image']); + } + + /** + * Runs the hook over the given tags. + */ + protected function alter(array &$metatags, ?NodeInterface $node = NULL): void { + $context = ['entity' => $node]; + $this->hook->alter($metatags, $context); + } + + /** + * Asserts the tags carry the site-wide image rather than one of their own. + */ + protected function assertFallbackImage(array $metatags): void { + $this->assertStringEndsWith('/modules/custom/do_base/assets/social-share.jpg', $metatags['og_image']); + $this->assertMatchesRegularExpression('#^https?://#', $metatags['og_image'], 'Both networks and schema.org reject a relative image URL.'); + $this->assertSame($metatags['og_image'], $metatags['twitter_cards_image']); + $this->assertSame('1200', $metatags['og_image_width']); + $this->assertSame('630', $metatags['og_image_height']); + $this->assertSame(static::SITE_NAME, $metatags['og_image_alt']); + $this->assertSame(static::SITE_NAME, $metatags['twitter_cards_image_alt']); + } + + /** + * Creates a page, optionally carrying a thumbnail. + */ + protected function createPage(?MediaInterface $thumbnail = NULL): NodeInterface { + $node = Node::create([ + 'type' => 'page', + 'title' => '[TEST] Page', + 'field_c_n_thumbnail' => $thumbnail instanceof MediaInterface ? ['target_id' => $thumbnail->id()] : NULL, + ]); + $node->save(); + + return $node; + } + + /** + * Creates a node of a bundle that has no thumbnail field at all. + */ + protected function createNodeWithoutThumbnailField(): NodeInterface { + NodeType::create(['type' => 'bare', 'name' => 'Bare'])->save(); + + $node = Node::create(['type' => 'bare', 'title' => '[TEST] Bare Page']); + $node->save(); + + return $node; + } + + /** + * Creates a media item wrapping an image file. + */ + protected function createImageMedia(FileInterface $file, string $alt): MediaInterface { + $media = Media::create([ + 'bundle' => static::MEDIA_TYPE, + 'name' => '[TEST] Media', + 'field_c_m_image' => ['target_id' => $file->id(), 'alt' => $alt], + ]); + $media->save(); + + return $media; + } + + /** + * Creates a file entity, optionally without writing the file itself. + */ + protected function createImageFile(string $extension = 'png', bool $write_file = TRUE): FileInterface { + $uri = 'public://' . $this->randomMachineName() . '.' . $extension; + + if ($write_file) { + $contents = $extension === 'svg' + ? '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"></svg>' + : (string) file_get_contents($this->root . '/core/tests/fixtures/files/image-1.png'); + + file_put_contents($uri, $contents); + } + + $file = File::create(['uri' => $uri]); + $file->setPermanent(); + $file->save(); + + return $file; + } + + /** + * Creates the media type shareable images live in. + */ + protected function createImageMediaType(): void { + $media_type = MediaType::create([ + 'id' => static::MEDIA_TYPE, + 'label' => 'Image', + 'source' => 'image', + ]); + $media_type->save(); + + FieldStorageConfig::create([ + 'entity_type' => 'media', + 'field_name' => 'field_c_m_image', + 'type' => 'image', + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'media', + 'bundle' => static::MEDIA_TYPE, + 'field_name' => 'field_c_m_image', + 'label' => 'Image', + 'settings' => ['alt_field' => TRUE, 'file_extensions' => 'png jpg svg'], + ])->save(); + + $media_type->set('source_configuration', ['source_field' => 'field_c_m_image'])->save(); + } + + /** + * Adds the thumbnail field pages carry. + */ + protected function createThumbnailField(): void { + FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_c_n_thumbnail', + 'type' => 'entity_reference', + 'settings' => ['target_type' => 'media'], + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'node', + 'bundle' => 'page', + 'field_name' => 'field_c_n_thumbnail', + 'label' => 'Thumbnail', + 'settings' => [ + 'handler' => 'default:media', + 'handler_settings' => ['target_bundles' => [static::MEDIA_TYPE => static::MEDIA_TYPE]], + ], + ])->save(); + } + +}