Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions config/default/core.extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions config/default/csp.settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -48,8 +47,6 @@ enforce:
base: none
font-src:
base: self
sources:
- 'https://fonts.gstatic.com'
connect-src:
base: self
sources:
Expand Down
21 changes: 21 additions & 0 deletions config/default/image.style.banner_background.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_campaign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_event_card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_medium.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_navigation_card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_promo_card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_publication_card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_slider_slide.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/default/image.style.civictheme_subject_card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions config/default/metatag.metatag_defaults.node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
4 changes: 2 additions & 2 deletions config/default/seckit.settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 15 additions & 2 deletions docs/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/sitemap.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ A bundle is included only when an `xmlsitemap.settings.<entity_type>.<bundle>` 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.
Expand Down
13 changes: 12 additions & 1 deletion tests/behat/features/csp.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions tests/behat/features/metatags.feature
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ Feature: Page content metatags
And the response should contain "<meta name=\"twitter:card\" content=\"summary_large_image\""
And the response should contain "<meta name=\"twitter:image\" content=\""

@api
Scenario: Page carries breadcrumb structured data
Given the following civictheme_page content:
| title | status |
| [TEST] Breadcrumb Page | 1 |
When I visit the "civictheme_page" content page with the title "[TEST] Breadcrumb Page"
Then the response should contain "\"@type\": \"WebPage\""
And the response should contain "\"@type\": \"BreadcrumbList\""
And the response should contain "\"@type\": \"ListItem\""

@api @blog
Scenario: Blog post is marked up as an article for search engines
Given the following blog content:
Expand Down
12 changes: 12 additions & 0 deletions tests/behat/features/xmlsitemap.feature
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ Feature: XML sitemap
And the response should contain "sitemap-indexed-page"
And the response should not contain "sitemap-excluded-page"

@api
Scenario: Sitemap lists URLs on the canonical host
Given I run drush "xmlsitemap:rebuild" "--yes"
And I am an anonymous user
When I go to "sitemap.xml"
Then the response status code should be 200
# Cron regenerates the sitemap with no request to take a host from, so the
# host is stated in settings. Any other host makes every listed URL a
# redirect, because that is what the site serves.
And the response should contain "<loc>https://www.drevops.com/"
And the response should not contain "<loc>https://drevops.com/"

@api @blog
Scenario: Sitemap lists published blog posts only
Given the following "blog" content:
Expand Down
Loading
Loading