diff --git a/AGENTS.md b/AGENTS.md index 77233f15..aa61a263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ The `docs/` directory contains **what** applies to this project: - `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/performance.md` - Image styles, self-hosted fonts and layout stability - `docs/preview-links.md` - sharing unpublished content by link - `docs/faqs.md` - Project-specific FAQs diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index db840c84..bae9afef 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -101,6 +101,7 @@ module: schema_article: 0 schema_metatag: 0 schema_organization: 0 + schema_web_page: 0 schema_web_site: 0 search_api: 0 search_api_db: 0 diff --git a/config/default/csp.settings.yml b/config/default/csp.settings.yml index 6e8cd5dc..a82fa1c6 100644 --- a/config/default/csp.settings.yml +++ b/config/default/csp.settings.yml @@ -27,7 +27,6 @@ enforce: - unsafe-inline sources: - 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/' - - 'https://fonts.googleapis.com/' - 'https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0.3/dist/css/tabby-ui.min.css' - 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.css' - 'https://unpkg.com/tippy.js@6.3.7/dist/tippy.css' @@ -48,8 +47,6 @@ enforce: base: none font-src: base: self - sources: - - 'https://fonts.gstatic.com' connect-src: base: self sources: diff --git a/config/default/image.style.banner_background.yml b/config/default/image.style.banner_background.yml new file mode 100644 index 00000000..b9dd095d --- /dev/null +++ b/config/default/image.style.banner_background.yml @@ -0,0 +1,21 @@ +uuid: 199c29c4-f94f-463c-9c3d-34693e08c00f +langcode: en +status: true +dependencies: { } +name: banner_background +label: 'Banner background (1920 wide, WebP)' +effects: + ebbff644-9a41-4bbb-8aae-5a9e75fc60f9: + uuid: ebbff644-9a41-4bbb-8aae-5a9e75fc60f9 + id: image_scale + weight: 1 + data: + width: 1920 + height: null + upscale: false + a4de6937-9de6-4ef4-a664-eaf57d7bd23c: + uuid: a4de6937-9de6-4ef4-a664-eaf57d7bd23c + id: image_convert + weight: 2 + data: + extension: webp diff --git a/config/default/image.style.civictheme_campaign.yml b/config/default/image.style.civictheme_campaign.yml index b54382e0..5a70b4a4 100644 --- a/config/default/image.style.civictheme_campaign.yml +++ b/config/default/image.style.civictheme_campaign.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + 73cdd7e6-446a-49e9-8d76-4f00844e8565: + uuid: 73cdd7e6-446a-49e9-8d76-4f00844e8565 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_event_card.yml b/config/default/image.style.civictheme_event_card.yml index b6a8f814..222ac504 100644 --- a/config/default/image.style.civictheme_event_card.yml +++ b/config/default/image.style.civictheme_event_card.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + 35a011ef-3bec-4c38-8df8-41d5ecb4ec61: + uuid: 35a011ef-3bec-4c38-8df8-41d5ecb4ec61 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_medium.yml b/config/default/image.style.civictheme_medium.yml index 67e8014b..28855327 100644 --- a/config/default/image.style.civictheme_medium.yml +++ b/config/default/image.style.civictheme_medium.yml @@ -17,3 +17,9 @@ effects: width: 220 height: 220 crop_type: focal_point + 6aac0b1d-d05e-4dbe-9eb7-1c6b8cc1391b: + uuid: 6aac0b1d-d05e-4dbe-9eb7-1c6b8cc1391b + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_navigation_card.yml b/config/default/image.style.civictheme_navigation_card.yml index f1ee4c91..18d26e77 100644 --- a/config/default/image.style.civictheme_navigation_card.yml +++ b/config/default/image.style.civictheme_navigation_card.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + ca8eb0c2-63fc-40b1-bcd9-3ea02c99a0f2: + uuid: ca8eb0c2-63fc-40b1-bcd9-3ea02c99a0f2 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_promo_card.yml b/config/default/image.style.civictheme_promo_card.yml index b8fed98e..7d6177d6 100644 --- a/config/default/image.style.civictheme_promo_card.yml +++ b/config/default/image.style.civictheme_promo_card.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + 2c66c544-d990-474c-8918-9d21e217b258: + uuid: 2c66c544-d990-474c-8918-9d21e217b258 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_publication_card.yml b/config/default/image.style.civictheme_publication_card.yml index e4c01d70..b78b1200 100644 --- a/config/default/image.style.civictheme_publication_card.yml +++ b/config/default/image.style.civictheme_publication_card.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + 83c1bf53-447c-408d-8d01-352705f4b064: + uuid: 83c1bf53-447c-408d-8d01-352705f4b064 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_slider_slide.yml b/config/default/image.style.civictheme_slider_slide.yml index 32ca5613..b8ac2e93 100644 --- a/config/default/image.style.civictheme_slider_slide.yml +++ b/config/default/image.style.civictheme_slider_slide.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + a0941e41-8d54-47b1-b53f-1df40d9fded3: + uuid: a0941e41-8d54-47b1-b53f-1df40d9fded3 + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/image.style.civictheme_subject_card.yml b/config/default/image.style.civictheme_subject_card.yml index 527ee967..ada9fce0 100644 --- a/config/default/image.style.civictheme_subject_card.yml +++ b/config/default/image.style.civictheme_subject_card.yml @@ -17,3 +17,9 @@ effects: width: 600 height: 600 crop_type: focal_point + b361ccb1-d7c8-4af4-92fc-0ef29face70f: + uuid: b361ccb1-d7c8-4af4-92fc-0ef29face70f + id: image_convert + weight: 3 + data: + extension: webp diff --git a/config/default/metatag.metatag_defaults.node.yml b/config/default/metatag.metatag_defaults.node.yml index e4f14abb..d57ba7cc 100644 --- a/config/default/metatag.metatag_defaults.node.yml +++ b/config/default/metatag.metatag_defaults.node.yml @@ -11,4 +11,6 @@ tags: description: '[node:summary]' og_updated_time: '[node:changed:custom:c]' og_url: '[node:url]' + schema_web_page_breadcrumb: 'Yes' + schema_web_page_type: WebPage title: '[node:title] | [site:name]' diff --git a/config/default/seckit.settings.yml b/config/default/seckit.settings.yml index b01d61b1..9395eb5c 100644 --- a/config/default/seckit.settings.yml +++ b/config/default/seckit.settings.yml @@ -8,13 +8,13 @@ seckit_xss: default-src: "'self'" script-src: "'self' https://www.googletagmanager.com https://www.gstatic.com https://www.recaptcha.net https://www.google.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0.3/dist/js/tabby.min.js https://unpkg.com/@popperjs/core@2.11.6/dist/umd/popper.js https://unpkg.com/tippy.js@6.3.7/dist/tippy.umd.js" object-src: "'none'" - style-src: "'self' https://cdnjs.cloudflare.com/ajax/libs/highlight.js/ 'unsafe-inline' https://fonts.googleapis.com/ https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0.3/dist/css/tabby-ui.min.css https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.css https://unpkg.com/tippy.js@6.3.7/dist/tippy.css https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css" + style-src: "'self' https://cdnjs.cloudflare.com/ajax/libs/highlight.js/ 'unsafe-inline' https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0.3/dist/css/tabby-ui.min.css https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.css https://unpkg.com/tippy.js@6.3.7/dist/tippy.css https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css" img-src: "'self' data:" media-src: "'self'" frame-src: "'self' https://www.youtube.com https://www.recaptcha.net https://www.google.com" frame-ancestors: "'none'" child-src: '' - font-src: "'self' https://fonts.gstatic.com" + font-src: "'self'" connect-src: "'self' https://www.googletagmanager.com https://www.google-analytics.com https://www.recaptcha.net https://www.google.com" report-uri: /report-csp-violation upgrade-req: true diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..4d283892 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,72 @@ +# Front-end performance + +This document describes what the site does to keep pages light and stable while they load. Meta tags and structured data are covered in [SEO](seo.md). + +## Images always go through an image style + +An image style does two things here: it caps the pixel dimensions, and it converts to WebP. Both matter, because the source files are large brand renders and a 4K PNG hero is several megabytes. + +| Style | Used for | +|---|---| +| `banner_background` | The banner background: scaled to 1920 wide, converted to WebP | +| `civictheme_*` | Cards, campaigns and slides: cropped by the CivicTheme sizes, converted to WebP | +| `wide` | The banner featured image | +| `social_share` | Share cards, deliberately **not** converted, because some social crawlers still handle WebP badly | + +CivicTheme resolves both banner images with no image style at all, which yields the URL of the original upload. `_drevops_banner_apply_image_styles()` re-resolves them. It covers the block field and the node field, because the node's value wins when both are set. + +When adding an image style that renders photographic content, copy the `image_convert` effect from `image.style.large.yml`. A style with only a crop effect ships whatever format was uploaded. + +## Images carry their own dimensions + +Without `width` and `height` a browser cannot reserve space for an image, so every image on the page moves the content under it as it arrives. + +The image component accepts both, but nothing that includes it passes them: the props are built from a media entity, which carries neither. `components/01-atoms/image/image.twig` overrides the CivicTheme component and fills them in through the `do_image_dimensions()` Twig function. + +That function works from the source file and the style's own transform rather than measuring the derivative, so the numbers are right even on the request that generates that derivative for the first time. Vectors have no raster size, so their ratio is read from the `viewBox`; only the ratio matters, since CSS decides the rendered size. + +Components are included from Twig rather than rendered through a render element, so there is no preprocess step and no `#pre_render` to hook. A Twig function called from the template is the only interception point. + +## Overriding a CivicTheme component + +Drupal matches components by name within an extension, so a same-named component in this theme is a new component rather than an override. The `replaces:` key in the `.component.yml` is what makes it an override: + +```yaml +name: Image +replaces: civictheme:image +``` + +Without it the theme's version is simply never used, and the rendered markup keeps saying `data-component-id="civictheme:image"`. + +**Copy the component's stylesheet along with its template.** A component's library is built from the files sitting in its own directory, so once `replaces:` points rendering at the override, the original's `.scss` is no longer part of what loads. An override carrying only a `.twig` renders the right markup with none of the component's CSS, which reads as the component being unstyled rather than as a missing file. `promo` and `pagination` both have one; `image` and `mobile-navigation-trigger` do not. + +Checking the markup is not enough to catch this, because the markup is correct. Compare a computed style against the same element on production, or look at the page. + +## Fonts are served from this origin + +Lexend and Rubik ship in `assets/fonts/` and are declared in `components/00-base/fonts/fonts.scss`. Nothing is fetched from `fonts.googleapis.com`, which is why the CSP no longer allows it. + +They are declared by hand rather than through CivicTheme's `$ct-fonts` map, because the generator that map feeds emits one `@font-face` per weight and supports neither the variable weight ranges these faces ship as nor the `unicode-range` and `font-display` descriptors a self-hosted face needs. The map still names the families, with an empty `types` list so it emits nothing. + +Only the Latin subsets are preloaded, by `_drevops_attach_font_preloads()`. The extended subsets are needed by a small minority of pages, and a preload the page does not use is a wasted request. + +Replacing a face means replacing the `woff2` files and the `unicode-range` values together. The ranges are Google's own subsetting boundaries and are what keeps a visitor reading only Latin text from downloading the extended file. + +## The banner background is preloaded + +The banner paints its background from CSS, so the browser cannot discover the file until the stylesheet has been fetched and parsed. On the pages carrying one, that background is the largest contentful paint. + +`_do_base_attach_banner_preload()` emits a `rel="preload"` for it. The URL has to be the one the stylesheet asks for, or the file is fetched twice: the preload resolves the same image style, and skips the whole thing when the file has no derivative. + +## Verifying a change + +```bash +ahoy test-kernel -- --filter=ImageDimensionsExtensionTest +``` + +For the rendered end, check that a page's images carry `width` and `height`, that no request goes to `fonts.googleapis.com`, and that image URLs contain `/styles/`. Lighthouse under mobile emulation is the quickest way to see the three metrics these affect: largest contentful paint, cumulative layout shift, and total byte weight. + +## Related + +- [SEO](seo.md) - meta tags, share cards and structured data +- [Sitemap](sitemap.md) - XML sitemap coverage and generation diff --git a/docs/seo.md b/docs/seo.md index 3a9260c6..15126f8a 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -11,7 +11,7 @@ This document describes the meta tags, share cards and structured data the site | `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 | +| `schema_organization`, `schema_web_site`, `schema_article`, `schema_web_page` | The 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 | @@ -25,7 +25,7 @@ Deliberately not installed: `yoast_seo` (a heavy analyser on every node form), ` - `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. +- A JSON-LD `@graph` carrying `Organization` and `WebSite`, plus `WebPage` with its `BreadcrumbList` on nodes and `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. @@ -40,6 +40,7 @@ Configuration holds the values that genuinely differ between pages: | `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 | +| `node` | `WebPage` and its breadcrumb, alongside the node title, description and URL | 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. @@ -54,6 +55,18 @@ A thumbnail is passed over in favour of the fallback when no image toolkit can d 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. +## Title and description length + +A search result shows roughly 60 characters of the title and 155 of the description, and cuts whatever is past that. + +Nothing trims a tag at render time. A description cut by a rule rather than by an author breaks mid-sentence and reads worse than a description written to fit, and the tag it produces is nobody's wording. Pages that need a shorter or a different one carry it explicitly in `field_n_metatags`, written by `do_base_deploy_set_seo_metatags()` from the values in `_do_base_seo_metatags()`. + +That hook replaces a tag only when the value it finds falls outside the lengths above, so wording an editor writes later is left alone and a repeat deployment is a no-op. Everything it writes is inside those lengths, which is what makes the second run do nothing. + +The titles it writes carry qualifiers the visible heading does not need: a page headed `GovCMS` is titled `GovCMS Development and Migration`, because the heading has a whole page for context and a search result has one line. They use `[site:name]` rather than a literal brand, matching the `node` default they replace. + +A page not listed there falls back to `[node:field_c_n_summary:value]`, which is as long as the summary an editor wrote. Adding a page to `_do_base_seo_metatags()` is the way to fix that, and it is worth checking a new page's rendered description against 155 characters rather than assuming the summary happens to fit. + ## Image assets Both live in `web/modules/custom/do_base/assets/` and are generated from the theme's brand assets: diff --git a/docs/sitemap.md b/docs/sitemap.md index c88651d0..2760cefd 100644 --- a/docs/sitemap.md +++ b/docs/sitemap.md @@ -20,6 +20,12 @@ A bundle is included only when an `xmlsitemap.settings..` c Only `xmlsitemap` itself is enabled. The `xmlsitemap_custom` and `xmlsitemap_engines` submodules are deliberately left off - the front page is covered natively by `frontpage_priority` and `frontpage_changefreq`, and pinging search engines on every change is not wanted. +## The host the URLs carry + +Cron regenerates the sitemap, and cron has no request to take a host from, so `xmlsitemap` keeps its own base URL. Left unset it falls back to a state value seeded at install time from whichever host ran the installer, and state is not exported configuration, so that value never travels with the code and never shows up in a diff. + +`web/sites/default/includes/modules/settings.xmlsitemap.php` states it instead. It has to match the host in the canonical tags and in `robots.txt`, because anything else makes every URL in the sitemap a redirect to the host the site actually serves. + ## Front page de-duplication `system.site:page.front` points at a node that has no path alias, so that node would otherwise be listed twice: once as `/` and once under its internal path. `do_base_xmlsitemap_link_alter()` drops the second entry, because two sitemap URLs serving identical content read as duplicate content to search engines. diff --git a/tests/behat/features/csp.feature b/tests/behat/features/csp.feature index 09d046fd..58cb81f5 100644 --- a/tests/behat/features/csp.feature +++ b/tests/behat/features/csp.feature @@ -33,5 +33,16 @@ Feature: Content Security Policy And the response header "Content-Security-Policy" should contain the value "https://www.googletagmanager.com" And the response header "Content-Security-Policy" should contain the value "https://www.recaptcha.net" And the response header "Content-Security-Policy" should contain the value "https://www.youtube.com" - And the response header "Content-Security-Policy" should contain the value "https://fonts.gstatic.com" And the response header "Content-Security-Policy" should contain the value "https://www.google-analytics.com" + + @api + Scenario: CSP allows no external font source + Given I am an anonymous user + When I go to the homepage + Then the response status code should be 200 + # Both faces are served from this origin, so a policy that still reaches + # out to Google is one a stylesheet could quietly start using again. There + # is no "font-src" to assert: the module omits a directive that matches + # "default-src", so fonts fall through to the "'self'" asserted above. + And the response header "Content-Security-Policy" should not contain the value "https://fonts.gstatic.com" + And the response header "Content-Security-Policy" should not contain the value "https://fonts.googleapis.com" diff --git a/tests/behat/features/metatags.feature b/tests/behat/features/metatags.feature index d32c09e4..df38df28 100644 --- a/tests/behat/features/metatags.feature +++ b/tests/behat/features/metatags.feature @@ -62,6 +62,16 @@ Feature: Page content metatags And the response should contain "https://www.drevops.com/" + And the response should not contain "https://drevops.com/" + @api @blog Scenario: Sitemap lists published blog posts only Given the following "blog" content: diff --git a/tests/phpunit/Drupal/EnvironmentSettingsTest.php b/tests/phpunit/Drupal/EnvironmentSettingsTest.php index 2f6d9bf9..7b7b26c8 100644 --- a/tests/phpunit/Drupal/EnvironmentSettingsTest.php +++ b/tests/phpunit/Drupal/EnvironmentSettingsTest.php @@ -279,6 +279,7 @@ public function testEnvironmentNoOverrides(): void { $settings['trusted_host_patterns'] = [ '^localhost$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -354,6 +355,7 @@ public function testEnvironmentOverrides(): void { '^localhost$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -437,6 +439,7 @@ public function testEnvironmentLocal(): void { $settings['trusted_host_patterns'] = [ '^localhost$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -492,6 +495,7 @@ public function testEnvironmentLocalContainer(): void { '^example-site\.docker\.amazee\.io$', '^nginx$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -546,6 +550,7 @@ public function testEnvironmentGha(): void { $settings['trusted_host_patterns'] = [ '^localhost$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -600,6 +605,7 @@ public function testEnvironmentLagoonPreview(): void { '^.+\.amazee\.io$', '^example1\.com|example2/com$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -654,6 +660,7 @@ public function testEnvironmentLagoonDev(): void { '^.+\.amazee\.io$', '^example1\.com|example2/com$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -708,6 +715,7 @@ public function testEnvironmentLagoonTest(): void { '^.+\.amazee\.io$', '^example1\.com|example2/com$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } @@ -762,6 +770,7 @@ public function testEnvironmentLagoonProd(): void { '^.+\.amazee\.io$', '^example1\.com|example2/com$', ]; + $settings['xmlsitemap_base_url'] = 'https://www.drevops.com'; $this->assertSettings($settings); } diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index ea353b8a..07116021 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -10,6 +10,7 @@ declare(strict_types=1); use Drupal\civictheme\CivicthemeColorManager; +use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\Sql\DefaultTableMapping; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; @@ -584,6 +585,72 @@ function do_base_deploy_seed_project_vocabularies(): string { return Helper::report(); } +/** + * Regenerates blog aliases that do not follow the pathauto pattern. + * + * @param array|null $sandbox + * Batch sandbox, matching the nullable reference the batch helper takes. + * + * @return string|null + * Summary once every alias is regenerated, or NULL while batching. + */ +function do_base_deploy_realias_blog_posts(?array &$sandbox = NULL): ?string { + $nids = \Drupal::entityQuery('node')->condition('type', 'blog')->accessCheck(FALSE)->execute(); + + if (empty($nids)) { + Helper::reporter()->skipped('There are no blog posts.'); + + return Helper::report(); + } + + // The query doubles as the idempotency guard: an alias regenerated onto the + // pattern no longer matches, so a repeat deployment finds nothing to do. + $query = \Drupal::entityQuery('path_alias') + ->condition('alias', '/blog/%', 'NOT LIKE') + ->condition('path', array_map(static fn(string $nid): string => '/node/' . $nid, $nids), 'IN'); + + return Helper::entity($sandbox)->batchQuery($query, static function (PathAliasInterface $alias): void { + _do_base_blog_realias($alias); + }, status: Reporter::UPDATED); +} + +/** + * Sets the search result title and description on the pages that need one. + * + * @param array|null $sandbox + * Batch sandbox, matching the nullable reference the batch helper takes. + * + * @return string|null + * Summary once every page is set, or NULL while batching. + */ +function do_base_deploy_set_seo_metatags(?array &$sandbox = NULL): ?string { + $overrides = _do_base_seo_metatags(); + $alias_manager = \Drupal::service('path_alias.manager'); + + $nids = []; + foreach (array_keys($overrides) as $alias) { + // The front page is reached through the site setting rather than an alias. + $path = $alias === '/' ? (string) \Drupal::config('system.site')->get('page.front') : $alias_manager->getPathByAlias($alias); + + if (preg_match('#^/node/(\d+)$#', $path, $matches)) { + $nids[(int) $matches[1]] = $alias; + } + } + + if (empty($nids)) { + Helper::reporter()->skipped('None of the pages carrying an override exist.'); + + return Helper::report(); + } + + $query = \Drupal::entityQuery('node')->condition('nid', array_keys($nids), 'IN'); + + return Helper::entity($sandbox, 10)->batchQuery($query, static function (NodeInterface $node) use ($nids, $overrides): void { + $alias = $nids[(int) $node->id()] ?? ''; + _do_base_set_seo_metatags($node, $overrides[$alias] ?? []); + }); +} + /** * Assembles the Our Work page. */ @@ -765,6 +832,216 @@ function _do_base_menu_leading_weight(string $menu_name): int { return min($weights) - 1; } +/** + * Writes a title and description onto a node. + * + * @param \Drupal\node\NodeInterface $node + * The node to write to. + * @param array $tags + * Metatag values keyed by tag name. + */ +function _do_base_set_seo_metatags(NodeInterface $node, array $tags): void { + if (empty($tags) || !$node->hasField('field_n_metatags')) { + return; + } + + $current = Json::decode((string) $node->get('field_n_metatags')->value) ?: []; + $changed = FALSE; + + foreach ($tags as $tag => $value) { + // Wording already inside the length a result shows is left alone, whoever + // wrote it. That also makes a repeat deployment a no-op, since everything + // written here is inside those bounds. + if (isset($current[$tag]) && _do_base_seo_metatag_fits($tag, (string) $current[$tag], $node)) { + continue; + } + + $current[$tag] = $value; + $changed = TRUE; + } + + if (!$changed) { + return; + } + + $node->set('field_n_metatags', Json::encode($current)); + $node->setNewRevision(FALSE); + $node->save(); + + Helper::reporter()->updated(sprintf('Set the search result wording on "%s".', $node->getTitle())); +} + +/** + * Reports whether a tag value fits what a search result shows. + * + * @param string $tag + * The metatag name. + * @param string $value + * The value, which may still carry tokens. + * @param \Drupal\node\NodeInterface $node + * The node the value belongs to, giving its tokens something to resolve + * against. A value is measured rendered, because that is the length a + * search result has to fit. + * + * @return bool + * TRUE when the value needs no replacing. + */ +function _do_base_seo_metatag_fits(string $tag, string $value, NodeInterface $node): bool { + // Plain, because a search result shows characters rather than markup: an + // escaped ampersand would otherwise count as five. + $length = mb_strlen(\Drupal::token()->replacePlain($value, ['node' => $node], ['clear' => TRUE])); + + // The lengths a search result shows before it cuts the value off. + return match ($tag) { + 'title' => $length >= 30 && $length <= 60, + 'description' => $length >= 70 && $length <= 155, + default => TRUE, + }; +} + +/** + * Returns the title and description to publish for each page, keyed by alias. + * + * The title is what a search result shows rather than what the page displays, + * so it carries the qualifiers a heading does not need. The description is + * written to sit inside the length a result will show. + * + * @return array> + * Metatag values keyed by tag name, keyed by path alias. + */ +function _do_base_seo_metatags(): array { + return [ + '/' => [ + 'description' => 'Australian Drupal and DevOps consultancy. We build and support Drupal platforms with senior engineering and automated testing from the first commit.', + ], + '/about-us' => [ + 'title' => 'About Us: Australian Drupal and DevOps Team | [site:name]', + 'description' => 'An Australian Drupal and DevOps consultancy since 2016, working with government, education and enterprise teams who need real engineering.', + ], + '/ai-assisted-delivery' => [ + 'description' => 'Already on Drupal? We cost the same senior, fully-tested work two ways, by hand and AI-assisted, so you see the difference before you change.', + ], + '/ai-integration-automation' => [ + 'description' => 'AI integration and automation for Drupal platforms, built with the same testing and review gates as everything else we ship.', + ], + '/blog' => [ + 'title' => 'Drupal and DevOps Blog | [site:name]', + 'description' => 'Real-world Drupal and DevOps practice from the DrevOps team: technical deep-dives, release notes, and what we have learned running open-source tools.', + ], + '/blog/ai-written-code-safe-what-we-put-around-it-it-ships' => [ + 'description' => 'Is AI-written code safe? AI speeds up the writing, never the checking. Every change is still reviewed, tested and gated before it ships.', + ], + '/blog/are-ai-restrictions-actually-growing-open-source' => [ + 'description' => 'AI policies meant to limit AI on private code have an unexpected effect: developers extract generic logic into public modules, and open source grows.', + ], + '/blog/becoming-maintainers-drupal-driver-and-drupal-extension-projects' => [ + 'description' => 'Alex Skrypnyk now co-maintains Drupal Driver and Drupal Extension. Three coordinated alphas of the Drupal Behat stack just shipped.', + ], + '/blog/how-we-upgrade-and-modernise-drupal-sites' => [ + 'description' => 'Off Drupal 7, or stuck on 9 or 10? How we upgrade: assess, modernise with Rector, migrate content, and prove it with automated tests.', + ], + '/blog/same-senior-drupal-work-about-third-less' => [ + 'description' => 'The same senior, tested Drupal work for about a third less on suitable work, because delivery got faster, not because corners were cut.', + ], + '/blog/vortex-134' => [ + 'description' => 'Vortex 1.34.0 (Flux) adds Composer security audits, Composer Patches v2, modernised dev dependencies, and moves end-to-end tests to PHPUnit.', + ], + '/blog/vortex-135-released' => [ + 'description' => 'Vortex 1.35 adds full Drupal 11.3 compatibility and updates container images, GitHub Actions and automation tooling for stability and security.', + ], + '/blog/vortex-136-released' => [ + 'description' => 'Vortex 1.36 makes the template AI-native and migration-ready, with structured agent support, CI-testable migrations and full-stack JS testing.', + ], + '/blog/vortex-137-released' => [ + 'description' => 'Vortex 1.37 extracts demo code into its own module, splits linting into a standalone CI job, and restructures the CircleCI config for maintenance.', + ], + '/blog/vortex-1390-photon-whats-release' => [ + 'description' => 'Vortex 1.39.0 adds opt-in visual regression testing, accessibility checks in the box, faster CI, and a reliability fix for Lagoon projects.', + ], + '/blog/vortex-1-40-0-spectrum' => [ + 'description' => 'Vortex 1.40.0 ships CI secret and workflow scanning, test results on your PRs, site-wide accessibility checks and SDC tooling out of the box.', + ], + '/blog/vortex-138-released' => [ + 'description' => 'Vortex 1.38 brings new testing, default modules, security and deployment improvements, plus a runtime bump. Here is what changed and why.', + ], + '/blog/vortex-2570-released' => [ + 'description' => 'Vortex 25.7.0 adds Drupal 11.2 support, faster CI, better Docker and Composer handling for local development, and a more robust installer.', + ], + '/blog/vortex-2580-released' => [ + 'description' => 'Vortex 25.8.0 adds Drupal CMS support, cuts CI build times by up to 5 minutes, improves Lagoon hosting, and significantly enhances the installer.', + ], + '/blog/what-ai-actually-doing-open-source' => [ + 'description' => 'From maintaining around 40 repos: AI speeds up consuming open source far faster than contributing back, and that gap is the real threat.', + ], + '/contact' => [ + 'title' => 'Contact Our Drupal Team | [site:name]', + 'description' => 'Talk to the engineers who would do the work. Tell us what you are running and what is not working, and we will tell you what we would do.', + ], + '/how-we-work' => [ + 'title' => 'How We Work: Process, Pricing, Delivery | [site:name]', + 'description' => 'What working with DrevOps looks like, from first conversation to go-live: what happens at each step and how we build a price with nothing hidden.', + ], + '/responsible-ai' => [ + 'title' => 'Responsible AI Policy | [site:name]', + 'description' => 'How DrevOps uses AI responsibly: safeguards around data security, governance and human oversight, so nothing you share trains a public model.', + ], + '/services' => [ + 'title' => 'Drupal and DevOps Services | [site:name]', + 'description' => 'Expert-led Drupal and DevOps services: builds, upgrades, migrations, automated testing, CI/CD and ongoing support for platforms that matter.', + ], + '/services/audit' => [ + 'title' => 'Drupal Site Audit and Health Check | [site:name]', + ], + '/services/automated-testing' => [ + 'title' => 'Automated Testing for Drupal | [site:name]', + ], + '/services/ci-cd-pipelines' => [ + 'description' => 'Automated CI/CD pipelines that test, build and deploy your Drupal application safely, using GitHub Actions, CircleCI, Azure Pipelines or GitLab CI.', + ], + '/services/civictheme' => [ + 'title' => 'CivicTheme Design System Development | [site:name]', + ], + '/services/containerisation' => [ + 'title' => 'Docker Containerisation for Drupal | [site:name]', + ], + '/services/drupal-upgrades' => [ + 'title' => 'Drupal Core and Contrib Upgrades | [site:name]', + ], + '/services/govcms' => [ + 'title' => 'GovCMS Development and Migration | [site:name]', + ], + '/services/migrations' => [ + 'title' => 'Drupal Migrations from Any CMS | [site:name]', + ], + '/services/support-plans' => [ + 'title' => 'SLA-backed Drupal Support Plans | [site:name]', + ], + '/work' => [ + 'description' => 'Drupal projects we have delivered and supported, from government platforms to community organisations, plus the open-source tools that came out of it.', + ], + ]; +} + +/** + * Regenerates the alias of the blog post a path alias points at. + */ +function _do_base_blog_realias(PathAliasInterface $alias): void { + if (!preg_match('#^/node/(\d+)$#', $alias->getPath(), $matches)) { + return; + } + + $node = \Drupal::entityTypeManager()->getStorage('node')->load($matches[1]); + + if (!$node instanceof NodeInterface || $node->bundle() !== 'blog') { + return; + } + + // Regenerating rather than rewriting the alias string keeps the result + // defined by the pattern alone, and lets the redirect module record the + // superseded path so inbound links keep resolving. + \Drupal::service('pathauto.generator')->updateEntityAlias($node, 'update'); +} + /** * Regenerates the alias of the project a path alias points at. */ diff --git a/web/modules/custom/do_base/do_base.module b/web/modules/custom/do_base/do_base.module index cfd55278..91a9506b 100644 --- a/web/modules/custom/do_base/do_base.module +++ b/web/modules/custom/do_base/do_base.module @@ -8,7 +8,11 @@ declare(strict_types=1); use Drupal\csp\Csp; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Site\Settings; +use Drupal\file\FileInterface; +use Drupal\image\Entity\ImageStyle; +use Drupal\media\MediaInterface; /** * Implements hook_mail_alter(). @@ -28,6 +32,70 @@ function do_base_mail_alter(array &$message): void { function do_base_page_attachments(array &$attachments): void { _do_base_attach_preview_link_robots($attachments); _do_base_attach_csp_nonce($attachments); + _do_base_attach_banner_preload($attachments); +} + +/** + * Preloads the banner background image. + * + * The banner paints its background from CSS, so the browser cannot discover the + * file until the stylesheet has been fetched and parsed. + */ +function _do_base_attach_banner_preload(array &$attachments): void { + $url = _do_base_banner_background_url(); + + if ($url === NULL) { + return; + } + + $attachments['#attached']['html_head_link'][] = [ + [ + 'rel' => 'preload', + 'href' => $url, + 'as' => 'image', + 'fetchpriority' => 'high', + ], + ]; +} + +/** + * Resolves the styled URL of the current node's banner background. + * + * @return string|null + * The URL, or NULL when there is no node, no background, or the file cannot + * be processed into a derivative. + */ +function _do_base_banner_background_url(): ?string { + $route_match = \Drupal::routeMatch(); + $node = $route_match->getParameter('node_revision') ?: $route_match->getParameter('node'); + + if (!$node instanceof FieldableEntityInterface || !$node->hasField('field_c_n_banner_background')) { + return NULL; + } + + $media = $node->get('field_c_n_banner_background')->entity; + if (!$media instanceof MediaInterface) { + return NULL; + } + + $source_field = $media->getSource()->getConfiguration()['source_field'] ?? NULL; + if (empty($source_field) || !$media->hasField($source_field)) { + return NULL; + } + + $file = $media->get($source_field)->entity; + if (!$file instanceof FileInterface) { + return NULL; + } + + $style = ImageStyle::load('banner_background'); + // A vector has no derivative, so the stylesheet requests the original and a + // preload of anything else would fetch the file twice. + if (!$style instanceof ImageStyle || !$style->supportsUri($file->getFileUri())) { + return NULL; + } + + return $style->buildUrl($file->getFileUri()); } /** diff --git a/web/modules/custom/do_base/do_base.services.yml b/web/modules/custom/do_base/do_base.services.yml index 19076cfb..24995679 100644 --- a/web/modules/custom/do_base/do_base.services.yml +++ b/web/modules/custom/do_base/do_base.services.yml @@ -4,6 +4,11 @@ services: arguments: ['@theme.manager'] tags: - { name: twig.extension } + do_base.twig.image_dimensions: + class: Drupal\do_base\Twig\ImageDimensionsExtension + arguments: ['@image.factory', '@stream_wrapper_manager', '@entity_type.manager', '%app.root%'] + tags: + - { name: twig.extension } do_base.theme_color_subscriber: class: Drupal\do_base\EventSubscriber\ThemeColorSubscriber arguments: ['@class_resolver', '@theme_handler'] diff --git a/web/modules/custom/do_base/src/Twig/ImageDimensionsExtension.php b/web/modules/custom/do_base/src/Twig/ImageDimensionsExtension.php new file mode 100644 index 00000000..1b3b38bf --- /dev/null +++ b/web/modules/custom/do_base/src/Twig/ImageDimensionsExtension.php @@ -0,0 +1,184 @@ +> + */ + protected array $resolved = []; + + public function __construct( + private readonly ImageFactory $imageFactory, + private readonly StreamWrapperManagerInterface $streamWrapperManager, + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly string $appRoot, + ) {} + + /** + * {@inheritdoc} + */ + public function getFunctions(): array { + return [ + new TwigFunction('do_image_dimensions', [$this, 'dimensions']), + ]; + } + + /** + * Resolves the rendered dimensions of an image URL. + * + * @param string|null $url + * Absolute or root-relative URL of the image. + * + * @return array + * Associative array of 'width' and 'height', empty when the file cannot be + * read as an image. + */ + public function dimensions(?string $url): array { + if ($url === NULL || $url === '') { + return []; + } + + $path = ltrim((string) parse_url($url, PHP_URL_PATH), '/'); + if ($path === '') { + return []; + } + + $wrapper = $this->streamWrapperManager->getViaScheme('public'); + $public_path = $wrapper instanceof PublicStream ? $wrapper->getDirectoryPath() : ''; + + if ($public_path !== '' && str_starts_with($path, $public_path . '/')) { + return $this->managedFile(urldecode(substr($path, strlen($public_path) + 1))); + } + + return $this->vector($path); + } + + /** + * Resolves dimensions for a file in the public files directory. + * + * Dimensions are calculated from the source file and the style's own + * transform rather than measured on the derivative, so they are correct even + * on the request that generates that derivative for the first time. + * + * @param string $relative + * Path relative to the public files directory. + * + * @return array + * Associative array of 'width' and 'height', or empty. + */ + protected function managedFile(string $relative): array { + // A listing renders the same image style over and over, and each miss here + // costs opening and parsing a file. + if (isset($this->resolved[$relative])) { + return $this->resolved[$relative]; + } + + $key = $relative; + $style = NULL; + + if (preg_match('~^styles/([^/]+)/[^/]+/(.+)$~', $relative, $matches) === 1) { + $style = $this->entityTypeManager->getStorage('image_style')->load($matches[1]); + $relative = $matches[2]; + } + + // A convert effect appends its own extension to the source file name, so a + // derivative path is not always a source path. + $candidates = [$relative]; + $trimmed = preg_replace('~\.[a-z0-9]+$~i', '', $relative); + if (!empty($trimmed) && $trimmed !== $relative) { + $candidates[] = $trimmed; + } + + foreach ($candidates as $candidate) { + $uri = 'public://' . $candidate; + $image = $this->imageFactory->get($uri); + + if (!$image->isValid()) { + continue; + } + + $dimensions = ['width' => $image->getWidth(), 'height' => $image->getHeight()]; + + if ($style instanceof ImageStyleInterface) { + $style->transformDimensions($dimensions, $uri); + } + + if (empty($dimensions['width']) || empty($dimensions['height'])) { + return $this->resolved[$key] = []; + } + + return $this->resolved[$key] = $dimensions; + } + + return $this->resolved[$key] = []; + } + + /** + * Resolves dimensions declared inside an SVG shipped with an extension. + * + * No image toolkit can measure a vector, so the ratio is read from the + * markup. Only the ratio matters here: the rendered size comes from CSS, and + * the attributes exist so the browser can reserve the right shape. + * + * @param string $path + * Path relative to the web root. + * + * @return array + * Associative array of 'width' and 'height', or empty. + */ + protected function vector(string $path): array { + if (!str_ends_with(strtolower($path), '.svg')) { + return []; + } + + // A path is only ever read once it is known to sit under the application + // root, so that a traversal in the URL cannot reach a file outside it. + $file = realpath($this->appRoot . '/' . $path); + if ($file === FALSE || !str_starts_with($file, $this->appRoot . '/')) { + return []; + } + + if (!is_file($file) || !is_readable($file)) { + return []; + } + + $markup = file_get_contents($file, FALSE, NULL, 0, 2048); + if ($markup === FALSE) { + return []; + } + + if (preg_match('~viewBox\s*=\s*["\']\s*[\d.+-]+[,\s]+[\d.+-]+[,\s]+([\d.]+)[,\s]+([\d.]+)~i', $markup, $matches) !== 1) { + return []; + } + + $width = (int) round((float) $matches[1]); + $height = (int) round((float) $matches[2]); + + if ($width <= 0 || $height <= 0) { + return []; + } + + return ['width' => $width, 'height' => $height]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Kernel/ImageDimensionsExtensionTest.php b/web/modules/custom/do_base/tests/src/Kernel/ImageDimensionsExtensionTest.php new file mode 100644 index 00000000..b4061ef7 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Kernel/ImageDimensionsExtensionTest.php @@ -0,0 +1,218 @@ +installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installConfig(['system', 'image']); + + $this->extension = $this->container->get('do_base.twig.image_dimensions'); + + $directory = 'public://do_test'; + $this->container->get('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + $this->writeImage($directory . '/source.png'); + } + + /** + * Tests that an unprocessed image reports the dimensions of the file. + */ + public function testOriginalReportsItsOwnDimensions(): void { + // Act. + $dimensions = $this->extension->dimensions($this->publicUrl('do_test/source.png')); + + // Assert. + $this->assertSame(['width' => static::SOURCE_WIDTH, 'height' => static::SOURCE_HEIGHT], $dimensions); + } + + /** + * Tests that a derivative reports the dimensions the style produces. + */ + public function testDerivativeReportsTheStyleDimensions(): void { + // Prepare. + $this->createScaleStyle('do_test_scale', 400); + + // Act. + $dimensions = $this->extension->dimensions($this->publicUrl('styles/do_test_scale/public/do_test/source.png')); + + // Assert. + $this->assertSame(['width' => 400, 'height' => 200], $dimensions); + } + + /** + * Tests that a converted derivative is traced back to its source file. + * + * A convert effect appends its extension to the source file name, so the + * derivative path is not a path any file sits at. + */ + public function testConvertedDerivativeIsTracedToItsSource(): void { + // Prepare. + $this->createScaleStyle('do_test_convert', 200, 'webp'); + + // Act. + $dimensions = $this->extension->dimensions($this->publicUrl('styles/do_test_convert/public/do_test/source.png.webp')); + + // Assert. + $this->assertSame(['width' => 200, 'height' => 100], $dimensions); + } + + /** + * Tests that a URL carrying a query string is still resolved. + */ + public function testQueryStringIsIgnored(): void { + // Act. + $dimensions = $this->extension->dimensions($this->publicUrl('do_test/source.png') . '?itok=abc123'); + + // Assert. + $this->assertSame(['width' => static::SOURCE_WIDTH, 'height' => static::SOURCE_HEIGHT], $dimensions); + } + + /** + * Tests that a vector reports the ratio declared in its markup. + */ + public function testVectorReportsItsViewBox(): void { + // Prepare. + $path = 'do_test_vector.svg'; + $file = \Drupal::root() . '/' . $path; + file_put_contents($file, ''); + + // Act. + $dimensions = $this->extension->dimensions('/' . $path); + + // Assert. + $this->assertSame(['width' => 120, 'height' => 60], $dimensions); + + unlink($file); + } + + /** + * Tests that a URL nothing can be measured from yields no dimensions. + */ + #[DataProvider('dataProviderUnmeasurableUrlYieldsNothing')] + public function testUnmeasurableUrlYieldsNothing(?string $url): void { + // Act. + $dimensions = $this->extension->dimensions($url); + + // Assert. + $this->assertSame([], $dimensions); + } + + /** + * Data provider for testUnmeasurableUrlYieldsNothing. + */ + public static function dataProviderUnmeasurableUrlYieldsNothing(): \Iterator { + yield 'null' => [NULL]; + yield 'empty' => ['']; + yield 'no path' => ['https://example.com']; + yield 'missing vector' => ['/themes/custom/absent.svg']; + } + + /** + * Tests that a managed file that cannot be read yields no dimensions. + */ + #[DataProvider('dataProviderUnreadableManagedFileYieldsNothing')] + public function testUnreadableManagedFileYieldsNothing(string $relative): void { + // Act. + $dimensions = $this->extension->dimensions($this->publicUrl($relative)); + + // Assert. + $this->assertSame([], $dimensions); + } + + /** + * Data provider for testUnreadableManagedFileYieldsNothing. + */ + public static function dataProviderUnreadableManagedFileYieldsNothing(): \Iterator { + yield 'absent file' => ['do_test/absent.png']; + yield 'absent derivative source' => ['styles/do_test_scale/public/do_test/absent.png']; + } + + /** + * Returns the URL a file in the public directory is served at. + */ + protected function publicUrl(string $relative): string { + $wrapper = $this->container->get('stream_wrapper_manager')->getViaScheme('public'); + $this->assertInstanceOf(PublicStream::class, $wrapper); + + return '/' . $wrapper->getDirectoryPath() . '/' . $relative; + } + + /** + * Writes a PNG of known dimensions. + */ + protected function writeImage(string $uri): void { + $path = $this->container->get('file_system')->realpath($uri); + $this->assertIsString($path); + + $resource = imagecreatetruecolor(static::SOURCE_WIDTH, static::SOURCE_HEIGHT); + imagepng($resource, $path); + imagedestroy($resource); + } + + /** + * Creates an image style that scales to a given width. + * + * @param string $name + * Machine name of the style. + * @param int $width + * Width to scale to. + * @param string|null $extension + * Extension to convert to, or NULL to leave the format alone. + */ + protected function createScaleStyle(string $name, int $width, ?string $extension = NULL): void { + $style = ImageStyle::create(['name' => $name, 'label' => $name]); + $style->addImageEffect([ + 'id' => 'image_scale', + 'data' => ['width' => $width, 'height' => NULL, 'upscale' => FALSE], + ]); + + if ($extension !== NULL) { + $style->addImageEffect(['id' => 'image_convert', 'data' => ['extension' => $extension]]); + } + + $style->save(); + } + +} diff --git a/web/sites/default/includes/modules/settings.xmlsitemap.php b/web/sites/default/includes/modules/settings.xmlsitemap.php new file mode 100644 index 00000000..6f61c2e0 --- /dev/null +++ b/web/sites/default/includes/modules/settings.xmlsitemap.php @@ -0,0 +1,13 @@ + +{% endif %} diff --git a/web/themes/custom/drevops/components/02-molecules/pagination/pagination.component.yml b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.component.yml new file mode 100644 index 00000000..89dc70d8 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.component.yml @@ -0,0 +1,146 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Pagination +status: stable +description: Component for navigating through multiple pages of content +replaces: civictheme:pagination +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + heading_id: + type: string + title: Heading ID + description: Heading ID for accessibility. + items_modifier_class: + type: string + title: Items modifier class + description: Additional classes for pagination items. + items_per_page_title: + type: string + title: Items per page title + description: Items per page field title. + default: 'Items per page' + items_per_page_name: + type: string + title: Items per page name + description: Items per page select field name. + items_per_page_id: + type: string + title: Items per page ID + description: Items per page select field ID. + items_per_page_attributes: + type: Drupal\Core\Template\Attribute + title: Items per page attributes + description: Items per page select element attributes. + use_ellipsis: + type: boolean + title: Use ellipsis + description: Whether ellipsis should be shown. + modifier_class: + type: string + title: Modifier classes + description: Additional CSS classes. + items: + type: object + title: Items + description: Object containing pagination items. + properties: + first: + type: object + title: First page + description: First page link. + properties: + text: + type: string + title: Text + description: Link text. + default: 'First' + href: + type: string + title: URL + description: Link URL. + previous: + type: object + title: Previous page + description: Previous page link. + properties: + text: + type: string + title: Text + description: Link text. + default: 'Prev' + href: + type: string + title: URL + description: Link URL. + pages: + type: object + title: Pages + description: Object containing page links. + next: + type: object + title: Next page + description: Next page link. + properties: + text: + type: string + title: Text + description: Link text. + default: 'Next' + href: + type: string + title: URL + description: Link URL. + last: + type: object + title: Last page + description: Last page link. + properties: + text: + type: string + title: Text + description: Link text. + default: 'Last' + href: + type: string + title: URL + description: Link URL. + items_per_page_options: + type: array + title: Items per page options + description: Options for items per page. + items: + type: object + properties: + type: + type: string + title: Type + description: Option type ('option'). + label: + type: string + title: Label + description: Option label. + value: + type: string + title: Value + description: Option value. + selected: + type: string + title: Selected + description: Whether the option is selected. +slots: + title: + title: Title + description: Active page title. + current: + title: Current page + description: Current page number. + total_pages: + title: Total pages + description: Total pages count. diff --git a/web/themes/custom/drevops/components/02-molecules/pagination/pagination.scss b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.scss new file mode 100644 index 00000000..70d6bceb --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.scss @@ -0,0 +1,103 @@ +// +// CivicTheme Pagination component styles. +// + +.ct-pagination { + $root: &; + + @include ct-typography('label-regular'); + + & { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: center; + width: auto; + border-top-width: ct-particle(0.125); + border-top-style: solid; + padding: ct-spacing(3); + box-sizing: border-box; + } + + @include ct-breakpoint(m) { + width: 100%; + border-bottom-width: ct-particle(0.125); + border-bottom-style: solid; + } + + #{$root}__items_per_page { + position: relative; + display: flex; + justify-content: center; + width: 100%; + margin-bottom: ct-spacing(4); + + @include ct-breakpoint(m) { + width: auto; + margin-bottom: 0; + } + } + + #{$root}__items_per_page__field { + align-self: center; + margin-bottom: 0; + white-space: nowrap; + } + + #{$root}__items_per_page__select { + min-width: ct-particle(10); + margin-left: ct-spacing(); + padding: 0 ct-spacing(2) 0 ct-spacing(2); + } + + #{$root}__items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + + @include ct-breakpoint(m) { + margin-left: auto; + } + } + + #{$root}__item--ellipsis { + padding: 0 ct-spacing(); + text-align: center; + } + + #{$root}__item--first, + #{$root}__item--previous { + padding: 0 ct-spacing(1) 0 0; + + @include ct-breakpoint(m) { + padding: 0 ct-spacing(2) 0 0; + } + } + + #{$root}__item--next, + #{$root}__item--last { + padding: 0 0 0 ct-spacing(1); + + @include ct-breakpoint(m) { + padding: 0 0 0 ct-spacing(2); + } + } + + #{$root}__item__link { + padding: 0 ct-spacing(); + white-space: nowrap; + } + + @include ct-component-theme($root) using($root, $theme) { + @include ct-component-property($root, $theme, color); + + & { + border-top-color: ct-component-var($root, $theme, border-color); + border-bottom-color: ct-component-var($root, $theme, border-color); + } + } +} diff --git a/web/themes/custom/drevops/components/02-molecules/pagination/pagination.twig b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.twig new file mode 100644 index 00000000..2fb9a7a4 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/pagination/pagination.twig @@ -0,0 +1,177 @@ +{# +/** + * @file + * CivicTheme Pagination component. + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - heading_id: [string] Heading ID for accessibility. + * - items_modifier_class: [string] Additional classes for pagination items. + * - items_per_page_title: [string] Items per page field title. + * - items_per_page_name: [string] Items per page select field name. + * - items_per_page_id: [string] Items per page select field ID. + * - items_per_page_attributes: [Drupal\Core\Template\Attribute] Items per page select element attributes. + * - use_ellipsis: [boolean] Whether ellipsis should be shown. + * - modifier_class: [string] Additional CSS classes. + * - items: [object] Object containing pagination items. + * Each property contains: + * - first: [object] First page link. + * Each property contains: + * - text: [string] Link text. + * - href: [string] Link URL. + * - previous: [object] Previous page link. + * Each property contains: + * - text: [string] Link text. + * - href: [string] Link URL. + * - pages: [object] Object containing page links. + * - next: [object] Next page link. + * Each property contains: + * - text: [string] Link text. + * - href: [string] Link URL. + * - last: [object] Last page link. + * Each property contains: + * - text: [string] Link text. + * - href: [string] Link URL. + * - items_per_page_options: [array] Options for items per page: + * Each item contains: + * - type: [string] Option type ('option'). + * - label: [string] Option label. + * - value: [string] Option value. + * - selected: [string] Whether the option is selected. + * - attributes: [Drupal\Core\Template\Attribute] Additional HTML attributes. + * + * Slots: + * - title: Active page title. + * - current: Current page number. + * - total_pages: Total pages count. + */ +#} + +{% set items_per_page_title = items_per_page_title|default('Items per page') %} +{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %} +{% set modifier_class = '%s %s'|format(theme_class, modifier_class|default('')) %} +{% set attributes = attributes is defined and attributes is not null ? attributes : create_attribute({}) %} + +{% if items is not empty %} + +{% endif %} diff --git a/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.component.yml b/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.component.yml new file mode 100644 index 00000000..03b45d9b --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.component.yml @@ -0,0 +1,32 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Mobile Navigation Trigger +status: stable +description: Button component for triggering the mobile navigation panel +replaces: civictheme:mobile-navigation-trigger +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + text: + type: string + title: Text + description: Button text. + icon: + type: string + title: Icon + description: Icon symbol name. + default: bars + modifier_class: + type: string + title: Modifier Class + description: Additional CSS classes. + attributes: + type: Drupal\Core\Template\Attribute + title: Attributes + description: Additional HTML attributes. diff --git a/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.twig b/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.twig new file mode 100644 index 00000000..aaa9db79 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/mobile-navigation/mobile-navigation-trigger.twig @@ -0,0 +1,30 @@ +{# +/** + * @file + * CivicTheme Mobile Navigation Trigger component. + * + * Attribute objects are mutable and shared with the caller, so the flyout + * attributes are set on a copy to keep them off the block wrapper. + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - text: [string] Button text. + * - icon: [string] Icon symbol name. + * - modifier_class: [string] Additional CSS classes. + * - attributes: [Drupal\Core\Template\Attribute] Additional HTML attributes. + */ +#} + +{% set modifier_class = 'ct-mobile-navigation-trigger %s'|format(modifier_class|default('')) %} +{% set trigger_attributes = create_attribute(attributes is defined and attributes is not null ? attributes.toArray() : {}) %} + +{% include 'civictheme:button' with { + theme: theme, + kind: 'button', + icon: icon|default('bars'), + icon_placement: 'after', + size: 'small', + text: text, + modifier_class: modifier_class, + attributes: trigger_attributes.setAttribute('data-flyout-open-trigger', '').setAttribute('data-flyout-target', '.ct-mobile-navigation').setAttribute('aria-expanded', 'false'), +} only %} diff --git a/web/themes/custom/drevops/components/03-organisms/promo/promo.component.yml b/web/themes/custom/drevops/components/03-organisms/promo/promo.component.yml new file mode 100644 index 00000000..67c61e3a --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/promo/promo.component.yml @@ -0,0 +1,74 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Promo +status: stable +description: Promotional component with title, content, and call-to-action link +replaces: civictheme:promo +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + is_contained: + type: boolean + title: Contained + description: Whether the content is contained in a container. + vertical_spacing: + type: string + title: Vertical Spacing + description: Vertical spacing for the promo (top, bottom, or both). + enum: + - top + - bottom + - both + - none + with_background: + type: boolean + title: With Background + description: Whether to display with a background. + modifier_class: + type: string + title: Modifier Class + description: Additional CSS classes. + attributes: + type: Drupal\Core\Template\Attribute + title: Attributes + description: Additional HTML attributes. + link: + type: object + title: Link + description: Call-to-action link. + properties: + text: + type: string + title: Text + description: Link text. + url: + type: string + title: URL + description: Link URL. + is_new_window: + type: boolean + title: New Window + description: Whether to open link in a new window. + is_external: + type: boolean + title: External + description: Whether the link is external. +slots: + content_top: + title: Content Top + description: Top content area. + title: + title: Title + description: Title text. + content: + title: Content + description: Main content text. + content_bottom: + title: Content Bottom + description: Bottom promo content. diff --git a/web/themes/custom/drevops/components/03-organisms/promo/promo.scss b/web/themes/custom/drevops/components/03-organisms/promo/promo.scss new file mode 100644 index 00000000..e2f73c14 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/promo/promo.scss @@ -0,0 +1,65 @@ +// +// CivicTheme Promo component styles. +// + +.ct-promo { + $root: &; + + &#{$root}--with-background { + @include ct-breakpoint(m) { + padding-left: var(--ct-promo-space-horizontal); + padding-right: var(--ct-promo-space-horizontal); + } + } + + & { + border-top: solid ct-particle(0.125); + border-bottom: solid ct-particle(0.125); + } + + &__wrapper { + padding: ct-spacing(3) 0; + + @include ct-breakpoint(m) { + padding: ct-spacing(7) 0; + display: flex; + align-items: center; + } + } + + &__inner { + @include ct-breakpoint(m) { + flex-grow: 1; + padding-right: ct-spacing(5); + } + } + + &__links { + margin-top: ct-spacing(3); + + @include ct-breakpoint(m) { + margin-top: 0; + } + } + + &__title { + margin-bottom: ct-spacing(1); + } + + &__content { + margin-left: auto; + } + + &__button { + width: auto; + + @include ct-breakpoint(m) { + white-space: nowrap; + } + } + + @include ct-component-theme($root) using($root, $theme) { + @include ct-component-property($root, $theme, background-color); + @include ct-component-property($root, $theme, border-color); + } +} diff --git a/web/themes/custom/drevops/components/03-organisms/promo/promo.twig b/web/themes/custom/drevops/components/03-organisms/promo/promo.twig new file mode 100644 index 00000000..2229041a --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/promo/promo.twig @@ -0,0 +1,115 @@ +{# +/** + * @file + * CivicTheme Promo component. + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - is_contained: [boolean] Whether the content is contained in a container. + * - vertical_spacing: [string] Vertical spacing for the promo (top, bottom, or both). + * - with_background: [boolean] Whether to display with a background. + * - modifier_class: [string] Additional CSS classes. + * - link: [object] Call-to-action link. + * Each property contains: + * - text: [string] Link text. + * - url: [string] Link URL. + * - is_new_window: [boolean] Whether to open link in a new window. + * - is_external: [boolean] Whether the link is external. + * - attributes: [Drupal\Core\Template\Attribute] Additional HTML attributes. + * + * Slots: + * - content_top: Top content area. + * - title: Title text. + * - content: Main content text. + * - content_bottom: Bottom promo content. + * + * Blocks: + * - content_top_block + * - content_block + * - content_left_block + * - content_right_block + * - content_bottom_block + */ +#} + +{% set with_background_class = with_background ? 'ct-promo--with-background' : '' %} +{% set vertical_spacing_class = vertical_spacing in ['top', 'bottom', 'both'] ? 'ct-vertical-spacing--%s'|format(vertical_spacing) : '' %} +{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %} +{% set modifier_class = '%s %s %s %s'|format(theme_class, with_background_class, vertical_spacing_class, modifier_class|default('')) %} + +{% set is_contained = is_contained is defined ? is_contained : true %} + +{% if title is not empty or content is not empty %} +
+ {% if is_contained %} +
+
+
+ {% endif %} + {% block content_top_block %} + {% if content_top is not empty %} +
+ {{ content_top }} +
+ {% endif %} + {% endblock %} + + {% block content_block %} +
+ {% block content_left_block %} +
+ {% if title %} + {# The utility class carries the type scale, which would + otherwise come from the heading element itself. #} + {% include 'civictheme:heading' with { + content: title, + level: 2, + theme: theme, + modifier_class: 'ct-promo__title ct-heading-4', + } only %} + {% endif %} + + {% if content is not empty %} + {% include 'civictheme:paragraph' with { + theme: theme, + content: content, + modifier_class: 'ct-promo__content', + } only %} + {% endif %} +
+ {% endblock %} + + {% block content_right_block %} + {% if link %} + + {% endif %} + {% endblock %} +
+ {% endblock %} + + {% block content_bottom_block %} + {% if content_bottom is not empty %} +
+ {{ content_bottom }} +
+ {% endif %} + {% endblock %} + {% if is_contained %} +
+
+
+ {% endif %} +
+{% endif %} diff --git a/web/themes/custom/drevops/components/variables.base.scss b/web/themes/custom/drevops/components/variables.base.scss index 222bd98c..4e36cd0d 100644 --- a/web/themes/custom/drevops/components/variables.base.scss +++ b/web/themes/custom/drevops/components/variables.base.scss @@ -9,9 +9,6 @@ // // stylelint-disable scss/dollar-variable-pattern -// Example to override existing CivicTheme's palette color and define -// custom colors, which will automatically appear in Storybook. - $ct-colors-brands: ( 'light': ( 'brand1': #00698f, @@ -24,10 +21,7 @@ $ct-colors-brands: ( 'brand3': #00698f, ) ); - -// Example to override existing CivicTheme's palette color variants. $ct-colors: ( - // Override palette color variant. 'light': ( 'success': green, 'custom1': brown, @@ -39,16 +33,19 @@ $ct-colors: ( ), ); -// Example to define 2 custom local fonts which will automatically appear -// in Storybook. +// Both faces are declared in 00-base/fonts/fonts.scss instead. The generator +// this map feeds emits one @font-face per weight, and supports neither the +// variable weight ranges these ship as nor the unicode-range and font-display +// descriptors they need. An empty 'types' list registers the family name +// without emitting anything. $ct-fonts: ( + 'primary': ( + 'family': '"Lexend", sans-serif', + 'types': (), + ), 'secondary': ( 'family': 'Rubik, sans-serif', - 'types': ( - ( - 'uri': 'https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap', - ), - ), + 'types': (), ), 'tertiary': ( 'family': 'Roboto, sans-serif', diff --git a/web/themes/custom/drevops/components/variables.components.scss b/web/themes/custom/drevops/components/variables.components.scss index 6105ebcc..5d2b3b77 100644 --- a/web/themes/custom/drevops/components/variables.components.scss +++ b/web/themes/custom/drevops/components/variables.components.scss @@ -65,9 +65,6 @@ $ct-icon-sizes: ( 'm': (ct-particle-px(3.5), ct-particle-px(3.5)), ), ); - -// Example to override component's variable - Back to Top should have a -// 'custom1' colour background. $ct-back-to-top-light-background-color: ct-color-light('custom1'); // diff --git a/web/themes/custom/drevops/drevops.theme b/web/themes/custom/drevops/drevops.theme index e28a0ecc..f83b7691 100644 --- a/web/themes/custom/drevops/drevops.theme +++ b/web/themes/custom/drevops/drevops.theme @@ -42,6 +42,13 @@ function _drevops_block_add_class(array &$variables, string $class): void { $variables['attributes']['class'][] = $class; } +/** + * Implements hook_page_attachments_alter(). + */ +function drevops_page_attachments_alter(array &$attachments): void { + _drevops_attach_font_preloads($attachments); +} + /** * Implements hook_preprocess_HOOK() for container templates. */ diff --git a/web/themes/custom/drevops/includes/banner.inc b/web/themes/custom/drevops/includes/banner.inc index f64c02c6..17a6d945 100644 --- a/web/themes/custom/drevops/includes/banner.inc +++ b/web/themes/custom/drevops/includes/banner.inc @@ -9,7 +9,9 @@ declare(strict_types=1); use Drupal\civictheme\CivicthemeConstants; use Drupal\Component\Render\MarkupInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Render\Markup; +use Drupal\media\MediaInterface; /** * Pre-process for Banner block. @@ -49,6 +51,32 @@ function _drevops_preprocess_block__civictheme_banner(array &$variables): void { if (!empty($variables['title']) && is_string($variables['title']) && !($variables['title'] instanceof MarkupInterface)) { $variables['title'] = Markup::create($variables['title']); } + + _drevops_banner_apply_image_styles($variables, $block, $node); +} + +/** + * Re-resolves banner images through image styles. + * + * CivicTheme resolves both of them with no style, which yields the URL of the + * original upload. + * + * @see civictheme_media_image_get_variables() + */ +function _drevops_banner_apply_image_styles(array &$variables, FieldableEntityInterface $block, mixed $node): void { + $background = $node instanceof FieldableEntityInterface ? civictheme_get_field_value($node, 'field_c_n_banner_background', TRUE) : NULL; + $background = $background ?: civictheme_get_field_value($block, 'field_c_b_background_image', TRUE, build: $variables); + + if ($background instanceof MediaInterface) { + $variables['background_image'] = civictheme_media_image_get_variables($background, 'banner_background'); + } + + $featured = $node instanceof FieldableEntityInterface ? civictheme_get_field_value($node, 'field_c_n_banner_featured_image', TRUE, build: $variables) : NULL; + $featured = $featured ?: civictheme_get_field_value($block, 'field_c_b_featured_image', TRUE, build: $variables); + + if ($featured instanceof MediaInterface) { + $variables['featured_image'] = civictheme_media_image_get_variables($featured, 'wide'); + } } /** diff --git a/web/themes/custom/drevops/includes/page.inc b/web/themes/custom/drevops/includes/page.inc index 6a3c8e9f..9f03994b 100644 --- a/web/themes/custom/drevops/includes/page.inc +++ b/web/themes/custom/drevops/includes/page.inc @@ -13,3 +13,32 @@ declare(strict_types=1); function _drevops_preprocess_page(array &$variables): void { $variables['header_is_sticky'] = civictheme_get_theme_config_manager()->load('components.header.is_sticky', FALSE); } + +/** + * Preloads the Latin subset of both brand faces. + * + * A face is only requested once the browser matches a rule that needs it, late + * enough that headings paint in the fallback and then reflow. Only the Latin + * subsets are preloaded, because a preload the page does not use is itself a + * wasted request. + */ +function _drevops_attach_font_preloads(array &$attachments): void { + $theme_path = \Drupal::service('extension.list.theme')->getPath('drevops'); + + $fonts = [ + '/' . $theme_path . '/dist/assets/fonts/lexend/lexend-latin.woff2', + '/' . $theme_path . '/dist/assets/fonts/rubik/rubik-latin.woff2', + ]; + + foreach ($fonts as $font) { + $attachments['#attached']['html_head_link'][] = [ + [ + 'rel' => 'preload', + 'href' => $font, + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin' => 'anonymous', + ], + ]; + } +}