From 151ec5a6c9b41abe93a20ee9a844f3164508318f Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Mon, 10 Aug 2026 21:51:15 +1000 Subject: [PATCH 1/7] [#296] Let an automated list follow the topics of the page it is on. --- AGENTS.md | 1 + ...raph.civictheme_automated_list.default.yml | 8 ++ ...raph.civictheme_automated_list.default.yml | 2 + ...d_list.field_c_p_list_topics_from_page.yml | 23 ++++ ...agraph.field_c_p_list_topics_from_page.yml | 18 +++ .../pathauto.pattern.civictheme_topics.yml | 24 ++++ config/default/views.view.taxonomy_term.yml | 30 +++-- ...ttings.taxonomy_term.civictheme_topics.yml | 3 + docs/related-content.md | 33 ++++++ ..._civictheme_automated_list_related.feature | 70 ++++++++++++ web/modules/custom/do_base/do_base.deploy.php | 80 ++++++++++++++ web/themes/custom/drevops/drevops.theme | 1 + .../drevops/includes/automated_list.inc | 104 ++++++++++++++++++ web/themes/custom/drevops/includes/node.inc | 23 +++- 14 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml create mode 100644 config/default/field.storage.paragraph.field_c_p_list_topics_from_page.yml create mode 100644 config/default/pathauto.pattern.civictheme_topics.yml create mode 100644 config/default/xmlsitemap.settings.taxonomy_term.civictheme_topics.yml create mode 100644 docs/related-content.md create mode 100644 tests/behat/features/paragraph_civictheme_automated_list_related.feature create mode 100644 web/themes/custom/drevops/includes/automated_list.inc diff --git a/AGENTS.md b/AGENTS.md index 422dee5a..31a11d2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,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/related-content.md` - Related-content lists and topic pages - `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.entity_form_display.paragraph.civictheme_automated_list.default.yml b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml index ded1d377..a04322df 100644 --- a/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml +++ b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml @@ -20,6 +20,7 @@ dependencies: - field.field.paragraph.civictheme_automated_list.field_c_p_list_link_below - field.field.paragraph.civictheme_automated_list.field_c_p_list_site_sections - field.field.paragraph.civictheme_automated_list.field_c_p_list_topics + - field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page - field.field.paragraph.civictheme_automated_list.field_c_p_list_type - field.field.paragraph.civictheme_automated_list.field_c_p_theme - field.field.paragraph.civictheme_automated_list.field_c_p_title @@ -330,6 +331,13 @@ content: size: 60 placeholder: '' third_party_settings: { } + field_c_p_list_topics_from_page: + type: boolean_checkbox + weight: 6 + region: content + settings: + display_label: true + third_party_settings: { } field_c_p_list_type: type: options_select weight: 12 diff --git a/config/default/core.entity_view_display.paragraph.civictheme_automated_list.default.yml b/config/default/core.entity_view_display.paragraph.civictheme_automated_list.default.yml index 3df221d8..30d70187 100644 --- a/config/default/core.entity_view_display.paragraph.civictheme_automated_list.default.yml +++ b/config/default/core.entity_view_display.paragraph.civictheme_automated_list.default.yml @@ -20,6 +20,7 @@ dependencies: - field.field.paragraph.civictheme_automated_list.field_c_p_list_link_below - field.field.paragraph.civictheme_automated_list.field_c_p_list_site_sections - field.field.paragraph.civictheme_automated_list.field_c_p_list_topics + - field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page - field.field.paragraph.civictheme_automated_list.field_c_p_list_type - field.field.paragraph.civictheme_automated_list.field_c_p_theme - field.field.paragraph.civictheme_automated_list.field_c_p_title @@ -187,4 +188,5 @@ hidden: field_c_p_list_feed_description: true field_c_p_list_feed_slug: true field_c_p_list_feed_title: true + field_c_p_list_topics_from_page: true search_api_excerpt: true diff --git a/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml b/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml new file mode 100644 index 00000000..96949b86 --- /dev/null +++ b/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml @@ -0,0 +1,23 @@ +uuid: e14c416c-d960-4b6f-997a-28b9d066b806 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_list_topics_from_page + - paragraphs.paragraphs_type.civictheme_automated_list +id: paragraph.civictheme_automated_list.field_c_p_list_topics_from_page +field_name: field_c_p_list_topics_from_page +entity_type: paragraph +bundle: civictheme_automated_list +label: 'Use topics of the current page' +description: 'Lists content sharing a topic with the page this list is placed on, and leaves that page out. Turning this on ignores the Topics chosen below.' +required: false +translatable: true +default_value: + - + value: 0 +default_value_callback: '' +settings: + on_label: 'On' + off_label: 'Off' +field_type: boolean diff --git a/config/default/field.storage.paragraph.field_c_p_list_topics_from_page.yml b/config/default/field.storage.paragraph.field_c_p_list_topics_from_page.yml new file mode 100644 index 00000000..1412ec9f --- /dev/null +++ b/config/default/field.storage.paragraph.field_c_p_list_topics_from_page.yml @@ -0,0 +1,18 @@ +uuid: 23a1da2f-ff37-48b0-8f36-884310059227 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_c_p_list_topics_from_page +field_name: field_c_p_list_topics_from_page +entity_type: paragraph +type: boolean +settings: { } +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/pathauto.pattern.civictheme_topics.yml b/config/default/pathauto.pattern.civictheme_topics.yml new file mode 100644 index 00000000..02953786 --- /dev/null +++ b/config/default/pathauto.pattern.civictheme_topics.yml @@ -0,0 +1,24 @@ +uuid: 5d9a1e32-1f80-40d0-8067-054765cf9763 +langcode: en +status: true +dependencies: + config: + - taxonomy.vocabulary.civictheme_topics + module: + - taxonomy +id: civictheme_topics +label: Topic +type: 'canonical_entities:taxonomy_term' +pattern: '/topics/[term:name]' +selection_criteria: + 9f1d5c7a-3e4b-4a2d-8c6f-1b7e2a9d4c53: + id: 'entity_bundle:taxonomy_term' + negate: false + context_mapping: + taxonomy_term: taxonomy_term + bundles: + civictheme_topics: civictheme_topics + uuid: 9f1d5c7a-3e4b-4a2d-8c6f-1b7e2a9d4c53 +selection_logic: and +weight: -5 +relationships: { } diff --git a/config/default/views.view.taxonomy_term.yml b/config/default/views.view.taxonomy_term.yml index 9d6719b8..e4645871 100644 --- a/config/default/views.view.taxonomy_term.yml +++ b/config/default/views.view.taxonomy_term.yml @@ -27,24 +27,27 @@ display: display_options: fields: { } pager: - type: mini + type: full options: offset: 0 pagination_heading_level: h4 - items_per_page: 10 - total_pages: 0 + items_per_page: 12 + total_pages: null id: 0 tags: - next: ›› - previous: ‹‹ + next: Next + previous: Previous + first: First + last: Last expose: items_per_page: false items_per_page_label: 'Items per page' - items_per_page_options: '5, 10, 25, 50' + items_per_page_options: '6, 12, 24, 48' items_per_page_options_all: false items_per_page_options_all_label: '- All -' offset: false offset_label: Offset + quantity: 5 exposed_form: type: basic options: @@ -214,16 +217,21 @@ display: default_group_multiple: { } group_items: { } style: - type: default + type: grid options: - grouping: { } - row_class: '' - default_row_class: true uses_fields: false + columns: 3 + automatic_width: true + alignment: horizontal + row_class_custom: '' + row_class_default: true + col_class_custom: '' + col_class_default: true row: type: 'entity:node' options: - view_mode: teaser + relationship: none + view_mode: civictheme_promo_card query: type: views_query options: diff --git a/config/default/xmlsitemap.settings.taxonomy_term.civictheme_topics.yml b/config/default/xmlsitemap.settings.taxonomy_term.civictheme_topics.yml new file mode 100644 index 00000000..903b546f --- /dev/null +++ b/config/default/xmlsitemap.settings.taxonomy_term.civictheme_topics.yml @@ -0,0 +1,3 @@ +status: true +priority: 0.5 +changefreq: 0 diff --git a/docs/related-content.md b/docs/related-content.md new file mode 100644 index 00000000..6eb05d5c --- /dev/null +++ b/docs/related-content.md @@ -0,0 +1,33 @@ +# Related content and topic pages + +Excluding the site navigation, most pages on this site had exactly one inbound internal link, and for the majority of them that link was a pager page. Nothing was unreachable, but a post published a while ago was reachable only by paging the blog listing. Two things address that, and they work together. + +## An Automated list can follow the page's own topics + +The Automated list paragraph carries a **Use topics of the current page** checkbox (`field_c_p_list_topics_from_page`). With it on, the list ignores the Topics chosen on the paragraph and matches the topics of the node it is placed on instead, which turns the same component into a related-content list. Nothing new was built to do this: it is the Automated list, configured differently. + +`drevops_civictheme_automated_list_view_alter()` in `web/themes/custom/drevops/includes/automated_list.inc` swaps the Topics contextual argument just before the view runs. CivicTheme fires that alter through the theme manager as well as the module handler, which is why this lives in the theme alongside the rest of the component's theming rather than in a module. + +Points worth knowing before changing it: + +- **The page being viewed is already excluded.** The view's fourth contextual filter is `nid` with `not` set, and it takes its value from the route, so a related list never lists the post it sits on. `_civictheme_automated_list__update_view()` passes only three arguments, which is what leaves that one to its route default. +- **The topics argument is position 1.** The arguments are ordered content type, topics, site sections. Reordering them in the view would silently point the swap at the wrong filter. +- **A post with no topics gets `none`, not `all`.** Without that, an empty topic set would fall through to the view's `all` default and advertise the whole site as related. +- **An empty result hides the heading.** `drevops_preprocess_paragraph__civictheme_automated_list()` clears the title when the list found nothing, so a post whose topics nothing else shares does not render a heading introducing empty space. + +`do_base_deploy_add_related_lists()` puts one of these lists on every existing blog post. It is skipped for a post that already has one, so it is safe to re-run. + +## Topic pages + +Topic terms are the second, stable inbound link: unlike a pager page, a topic page does not change what it points at as content is added. + +- `pathauto.pattern.civictheme_topics` puts them at `/topics/`. +- `do_base_deploy_alias_topic_terms()` hands every existing topic back to that pattern. Topics created programmatically carry `PathautoState::SKIP`, which is why most of them had no alias at all and a bulk generate would not give them one. +- The topic tags at the foot of a post link to these pages. `_drevops_node_add_topic_tags()` reads the referenced terms rather than their labels so each tag gets a `url`, which is what the `civictheme:tag` component turns into a link. +- `views.view.taxonomy_term` renders term pages as a CivicTheme promo-card grid with a full pager, rather than core's teaser list. This is the shared term view, so it applies to every vocabulary - acceptable because no other vocabulary's terms are linked from the site. +- Topic pages are listed in the XML sitemap. + +## Related + +- [SEO](seo.md) - meta tags, social share cards and structured data +- [Development agreements](development.md) - deploy hook conventions these follow diff --git a/tests/behat/features/paragraph_civictheme_automated_list_related.feature b/tests/behat/features/paragraph_civictheme_automated_list_related.feature new file mode 100644 index 00000000..14a199b5 --- /dev/null +++ b/tests/behat/features/paragraph_civictheme_automated_list_related.feature @@ -0,0 +1,70 @@ +@p0 @civictheme @civictheme_automated_list +Feature: Automated list following the page's own topics + + As a site visitor + I want a post to link to others on the same topic + So that I can keep reading without going back to a paginated listing + + Background: + Given the following "civictheme_topics" terms: + | name | + | [TEST] Shared Topic | + | [TEST] Other Topic | + + And the following "blog" content: + | title | moderation_state | created | field_c_n_topics | + | [TEST] Post Being Read | published | [relative:-1 day] | [TEST] Shared Topic | + | [TEST] Post Same Topic | published | [relative:-2 days] | [TEST] Shared Topic | + | [TEST] Post Other Topic | published | [relative:-3 days] | [TEST] Other Topic | + | [TEST] Post Without Topic | published | [relative:-4 days] | | + + And the following fields for the paragraph "civictheme_automated_list" exist in the field "field_c_n_components" within the "blog" "node" identified by the field "title" and the value "[TEST] Post Being Read": + | field_c_p_title | [TEST] Related posts | + | field_c_p_list_type | civictheme_automated_list__block1 | + | field_c_p_list_content_type | blog | + | field_c_p_list_limit_type | limited | + | field_c_p_list_limit | 3 | + | field_c_p_list_topics_from_page | 1 | + + @api + Scenario: The list shows posts sharing the page's topic + Given I am an anonymous user + + When I visit the "blog" content page with the title "[TEST] Post Being Read" + Then I should see the text "[TEST] Related posts" + And I should see "[TEST] Post Same Topic" + + @api + Scenario: The list leaves out the page it is on and posts on other topics + Given I am an anonymous user + + When I visit the "blog" content page with the title "[TEST] Post Being Read" + Then I should not see "[TEST] Post Other Topic" + And I should not see "[TEST] Post Without Topic" + And I should see 1 ".ct-promo-card" elements + + @api + Scenario: A post whose topics nothing shares shows no heading + Given the following fields for the paragraph "civictheme_automated_list" exist in the field "field_c_n_components" within the "blog" "node" identified by the field "title" and the value "[TEST] Post Without Topic": + | field_c_p_title | [TEST] Related posts | + | field_c_p_list_type | civictheme_automated_list__block1 | + | field_c_p_list_content_type | blog | + | field_c_p_list_limit_type | limited | + | field_c_p_list_limit | 3 | + | field_c_p_list_topics_from_page | 1 | + + And I am an anonymous user + + When I visit the "blog" content page with the title "[TEST] Post Without Topic" + Then I should not see the text "[TEST] Related posts" + + @api + Scenario: A post links to the topic pages it belongs to + Given I am an anonymous user + + When I visit the "blog" content page with the title "[TEST] Post Being Read" + Then I should see the link "[TEST] Shared Topic" + + When I click "[TEST] Shared Topic" + Then I should see "[TEST] Post Same Topic" + And I should see "[TEST] Post Being Read" diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 07116021..dbe265a3 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -23,6 +23,7 @@ use Drupal\media\MediaInterface; use Drupal\menu_link_content\MenuLinkContentInterface; use Drupal\node\NodeInterface; +use Drupal\paragraphs\Entity\Paragraph; use Drupal\paragraphs\ParagraphInterface; use Drupal\path_alias\PathAliasInterface; use Drupal\pathauto\PathautoState; @@ -1249,3 +1250,82 @@ function _do_base_blog_reindex(int|string $nid, array $langcodes): void { } } } + +/** + * Gives every topic term a URL alias so topic pages are reachable. + */ +function do_base_deploy_alias_topic_terms(?array &$sandbox = NULL): ?string { + $query = \Drupal::entityQuery('taxonomy_term') + ->accessCheck(FALSE) + ->condition('vid', 'civictheme_topics'); + + return Helper::entity($sandbox, 25)->batchQuery($query, static function (TermInterface $term): void { + _do_base_alias_topic_term($term); + }, status: Reporter::UPDATED); +} + +/** + * Puts a topic term back under the alias pattern and saves it. + */ +function _do_base_alias_topic_term(TermInterface $term): void { + if (!$term->hasField('path')) { + return; + } + + // Terms created programmatically carry SKIP, which is why most topics have + // no alias at all. Handing them back to the pattern is what puts them on + // /topics/, and it keeps them there when an editor renames one. + $term->set('path', ['pathauto' => PathautoState::CREATE]); + $term->save(); +} + +/** + * Adds a related-content list to the foot of every blog post. + */ +function do_base_deploy_add_related_lists(?array &$sandbox = NULL): ?string { + $query = \Drupal::entityQuery('node') + ->accessCheck(FALSE) + ->condition('type', 'blog'); + + return Helper::entity($sandbox, 10)->batchQuery($query, static function (NodeInterface $node): void { + _do_base_add_related_list($node); + }, status: Reporter::UPDATED); +} + +/** + * Appends an Automated list configured to follow the post's own topics. + */ +function _do_base_add_related_list(NodeInterface $node): void { + if (!$node->hasField('field_c_n_components')) { + return; + } + + foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { + if ($existing instanceof ParagraphInterface && $existing->bundle() === 'civictheme_automated_list' && !$existing->get('field_c_p_list_topics_from_page')->isEmpty() && (bool) $existing->get('field_c_p_list_topics_from_page')->value) { + return; + } + } + + $paragraph = Paragraph::create([ + 'type' => 'civictheme_automated_list', + 'field_c_p_title' => 'Related posts', + 'field_c_p_list_type' => 'civictheme_automated_list__block1', + 'field_c_p_list_content_type' => 'blog', + 'field_c_p_list_limit_type' => 'limited', + 'field_c_p_list_limit' => 3, + 'field_c_p_list_column_count' => 3, + 'field_c_p_list_topics_from_page' => 1, + ]); + $paragraph->setParentEntity($node, 'field_c_n_components'); + $paragraph->save(); + + $components = $node->get('field_c_n_components')->getValue(); + $components[] = [ + 'target_id' => $paragraph->id(), + 'target_revision_id' => $paragraph->getRevisionId(), + ]; + + $node->set('field_c_n_components', $components); + $node->setNewRevision(FALSE); + $node->save(); +} diff --git a/web/themes/custom/drevops/drevops.theme b/web/themes/custom/drevops/drevops.theme index f83b7691..8e52a774 100644 --- a/web/themes/custom/drevops/drevops.theme +++ b/web/themes/custom/drevops/drevops.theme @@ -14,6 +14,7 @@ require_once __DIR__ . '/includes/banner.inc'; require_once __DIR__ . '/includes/paragraphs.inc'; require_once __DIR__ . '/includes/divider.inc'; require_once __DIR__ . '/includes/manual_list.inc'; +require_once __DIR__ . '/includes/automated_list.inc'; require_once __DIR__ . '/includes/steps.inc'; require_once __DIR__ . '/includes/page.inc'; require_once __DIR__ . '/includes/node.inc'; diff --git a/web/themes/custom/drevops/includes/automated_list.inc b/web/themes/custom/drevops/includes/automated_list.inc new file mode 100644 index 00000000..64ca94c9 --- /dev/null +++ b/web/themes/custom/drevops/includes/automated_list.inc @@ -0,0 +1,104 @@ +component_settings ?? NULL; + $paragraph = is_array($settings) ? ($settings['paragraph'] ?? NULL) : NULL; + + if (!$paragraph instanceof FieldableEntityInterface) { + return; + } + + if (!_drevops_automated_list_follows_page($paragraph)) { + return; + } + + $topics = _drevops_automated_list_page_topics(); + + // The arguments are ordered content type, topics, site sections. + // @see _civictheme_automated_list__update_view() + $arguments = $view->args; + + // With the option on but no topic to match, the list would otherwise fall + // back to 'all' and advertise the whole site as related. + $arguments[1] = $topics === [] ? 'none' : implode('+', $topics); + + $view->setArguments($arguments); +} + +/** + * Tells whether a list takes its topics from the page it is placed on. + */ +function _drevops_automated_list_follows_page(FieldableEntityInterface $paragraph): bool { + return (bool) civictheme_get_field_value($paragraph, 'field_c_p_list_topics_from_page'); +} + +/** + * Implements template_preprocess_paragraph(). + */ +function drevops_preprocess_paragraph__civictheme_automated_list(array &$variables): void { + $paragraph = $variables['paragraph'] ?? NULL; + + if (!$paragraph instanceof FieldableEntityInterface) { + return; + } + + if (!_drevops_automated_list_follows_page($paragraph)) { + return; + } + + // A post whose topics nothing else shares would otherwise leave a heading + // introducing an empty space. + if (_drevops_automated_list_is_empty($variables['rows'] ?? NULL)) { + $variables['title'] = NULL; + $variables['rows'] = NULL; + } +} + +/** + * Tells whether a rendered view produced any rows. + */ +function _drevops_automated_list_is_empty(mixed $rows): bool { + if (empty($rows)) { + return TRUE; + } + + return is_array($rows) && empty($rows['#view']->result ?? []) && empty($rows['view_build']['#view']->result ?? []); +} + +/** + * Collects the topic term ids of the page currently being viewed. + * + * @return int[] + * Term ids, empty when there is no node in the route or it carries none. + */ +function _drevops_automated_list_page_topics(): array { + $route_match = \Drupal::routeMatch(); + $node = $route_match->getParameter('node_revision') ?: $route_match->getParameter('node'); + + if (!$node instanceof FieldableEntityInterface || !$node->hasField('field_c_n_topics')) { + return []; + } + + $ids = []; + + foreach ($node->get('field_c_n_topics')->referencedEntities() as $term) { + if ($term->access('view')) { + $ids[] = (int) $term->id(); + } + } + + return $ids; +} diff --git a/web/themes/custom/drevops/includes/node.inc b/web/themes/custom/drevops/includes/node.inc index 8ff39180..a0d403cc 100644 --- a/web/themes/custom/drevops/includes/node.inc +++ b/web/themes/custom/drevops/includes/node.inc @@ -37,15 +37,32 @@ function _drevops_node_add_topic_tags(NodeInterface $node, array &$variables): v return; } - $topics = civictheme_get_referenced_entity_labels($node, 'field_c_n_topics', $variables); + if (!$node->hasField('field_c_n_topics')) { + return; + } + + $tags = []; + + foreach ($node->get('field_c_n_topics')->referencedEntities() as $term) { + if (!$term->access('view')) { + continue; + } + + // A tag with a URL renders as a link, which is what gives the post a + // second inbound route that does not shift as the listing paginates. + $tags[] = [ + 'content' => (string) $term->label(), + 'url' => $term->toUrl()->toString(), + ]; + } - if (empty($topics)) { + if ($tags === []) { return; } $variables['content'][] = [ '#theme' => 'civictheme_tag_list', - '#tags' => array_map(static fn($label): array => ['content' => (string) $label], $topics), + '#tags' => $tags, '#vertical_spacing' => 'both', '#weight' => 100, ]; From e796cb90f1a4c0ba014130d34ade982ba13b4ce2 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Mon, 10 Aug 2026 21:56:13 +1000 Subject: [PATCH 2/7] Addressed code review: made the list option non-translatable. --- ...ivictheme_automated_list.field_c_p_list_topics_from_page.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml b/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml index 96949b86..9f63cf49 100644 --- a/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml +++ b/config/default/field.field.paragraph.civictheme_automated_list.field_c_p_list_topics_from_page.yml @@ -12,7 +12,7 @@ bundle: civictheme_automated_list label: 'Use topics of the current page' description: 'Lists content sharing a topic with the page this list is placed on, and leaves that page out. Turning this on ignores the Topics chosen below.' required: false -translatable: true +translatable: false default_value: - value: 0 From c7f091419e848cf78204bc621e810c1b5f8b7f78 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 11 Aug 2026 09:22:03 +1000 Subject: [PATCH 3/7] Recorded topic terms against the render array when building tags. --- web/themes/custom/drevops/includes/node.inc | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/web/themes/custom/drevops/includes/node.inc b/web/themes/custom/drevops/includes/node.inc index a0d403cc..b8c38a18 100644 --- a/web/themes/custom/drevops/includes/node.inc +++ b/web/themes/custom/drevops/includes/node.inc @@ -7,6 +7,7 @@ declare(strict_types=1); +use Drupal\Component\Utility\Xss; use Drupal\node\NodeInterface; /** @@ -37,29 +38,26 @@ function _drevops_node_add_topic_tags(NodeInterface $node, array &$variables): v return; } - if (!$node->hasField('field_c_n_topics')) { + // The helper access-checks the terms and records them against the render + // array, so the tag list is invalidated when a topic is renamed or its + // access changes. Reading the field directly would drop both. + $terms = civictheme_get_field_referenced_entities($node, 'field_c_n_topics', $variables); + + if (empty($terms)) { return; } $tags = []; - foreach ($node->get('field_c_n_topics')->referencedEntities() as $term) { - if (!$term->access('view')) { - continue; - } - + foreach ($terms as $term) { // A tag with a URL renders as a link, which is what gives the post a // second inbound route that does not shift as the listing paginates. $tags[] = [ - 'content' => (string) $term->label(), + 'content' => Xss::filter((string) $term->label()), 'url' => $term->toUrl()->toString(), ]; } - if ($tags === []) { - return; - } - $variables['content'][] = [ '#theme' => 'civictheme_tag_list', '#tags' => $tags, From 49a172df4a297ab86dddb629c1509d23fc6b0df6 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 11 Aug 2026 09:23:40 +1000 Subject: [PATCH 4/7] Removed comments narrating the change rather than the code. --- web/themes/custom/drevops/includes/node.inc | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/themes/custom/drevops/includes/node.inc b/web/themes/custom/drevops/includes/node.inc index b8c38a18..8dca5274 100644 --- a/web/themes/custom/drevops/includes/node.inc +++ b/web/themes/custom/drevops/includes/node.inc @@ -38,9 +38,6 @@ function _drevops_node_add_topic_tags(NodeInterface $node, array &$variables): v return; } - // The helper access-checks the terms and records them against the render - // array, so the tag list is invalidated when a topic is renamed or its - // access changes. Reading the field directly would drop both. $terms = civictheme_get_field_referenced_entities($node, 'field_c_n_topics', $variables); if (empty($terms)) { @@ -50,8 +47,6 @@ function _drevops_node_add_topic_tags(NodeInterface $node, array &$variables): v $tags = []; foreach ($terms as $term) { - // A tag with a URL renders as a link, which is what gives the post a - // second inbound route that does not shift as the listing paginates. $tags[] = [ 'content' => Xss::filter((string) $term->label()), 'url' => $term->toUrl()->toString(), From 48072058a680a6063750ed82cca207b718a1f10f Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 11 Aug 2026 09:40:35 +1000 Subject: [PATCH 5/7] Rendered term pages as a snippet list and generated related lists on blog posts. --- config/default/views.view.taxonomy_term.yml | 4 ++-- .../src/Generator/ComponentGenerator.php | 24 +++++++++++++++++++ .../src/Plugin/GeneratedContent/NodeBlog.php | 8 ++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/config/default/views.view.taxonomy_term.yml b/config/default/views.view.taxonomy_term.yml index e4645871..d7dd5d90 100644 --- a/config/default/views.view.taxonomy_term.yml +++ b/config/default/views.view.taxonomy_term.yml @@ -220,7 +220,7 @@ display: type: grid options: uses_fields: false - columns: 3 + columns: 1 automatic_width: true alignment: horizontal row_class_custom: '' @@ -231,7 +231,7 @@ display: type: 'entity:node' options: relationship: none - view_mode: civictheme_promo_card + view_mode: civictheme_snippet query: type: views_query options: diff --git a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php index 02b5b30b..271da263 100644 --- a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php +++ b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php @@ -162,6 +162,30 @@ public function create(string $bundle, int $index, int $position = 0): ?Paragrap return $paragraph; } + /** + * Build an Automated list that follows the topics of the page it sits on. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * The saved paragraph. + */ + public function relatedList(): Paragraph { + $paragraph = Paragraph::create([ + 'type' => 'civictheme_automated_list', + 'field_c_p_title' => 'Related posts', + 'field_c_p_list_type' => 'civictheme_automated_list__block1', + 'field_c_p_list_content_type' => 'blog', + 'field_c_p_list_limit_type' => 'limited', + 'field_c_p_list_limit' => 3, + 'field_c_p_list_column_count' => 3, + 'field_c_p_list_topics_from_page' => 1, + ]); + $paragraph->save(); + + $this->createdBundles['civictheme_automated_list'] = 'civictheme_automated_list'; + + return $paragraph; + } + /** * Bundles built so far, at every nesting level. * diff --git a/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php b/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php index b185f7d4..7940feb7 100644 --- a/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php +++ b/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php @@ -30,7 +30,13 @@ class NodeBlog extends NodeGeneratorBase { * {@inheritdoc} */ protected function bundleValues(int $index): array { - return $this->commonValues($index) + $this->bannerValues($index) + $this->pageValues($index); + $values = $this->commonValues($index) + $this->bannerValues($index) + $this->pageValues($index); + + // Mirrors what the deploy hook puts on every real blog post, so generated + // content exercises the related list rather than only production does. + $values['field_c_n_components'][] = $this->generator()->relatedList(); + + return $values; } } From 9b0dfc9a2ea8d310e3e5926b2dffd7070c2a021e Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 11 Aug 2026 10:00:55 +1000 Subject: [PATCH 6/7] Exercised the page-topics option through the generated automated list. --- .../src/Generator/ComponentGenerator.php | 28 +++---------------- .../src/Plugin/GeneratedContent/NodeBlog.php | 8 +----- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php index 271da263..38fae103 100644 --- a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php +++ b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php @@ -162,30 +162,6 @@ public function create(string $bundle, int $index, int $position = 0): ?Paragrap return $paragraph; } - /** - * Build an Automated list that follows the topics of the page it sits on. - * - * @return \Drupal\paragraphs\Entity\Paragraph - * The saved paragraph. - */ - public function relatedList(): Paragraph { - $paragraph = Paragraph::create([ - 'type' => 'civictheme_automated_list', - 'field_c_p_title' => 'Related posts', - 'field_c_p_list_type' => 'civictheme_automated_list__block1', - 'field_c_p_list_content_type' => 'blog', - 'field_c_p_list_limit_type' => 'limited', - 'field_c_p_list_limit' => 3, - 'field_c_p_list_column_count' => 3, - 'field_c_p_list_topics_from_page' => 1, - ]); - $paragraph->save(); - - $this->createdBundles['civictheme_automated_list'] = 'civictheme_automated_list'; - - return $paragraph; - } - /** * Bundles built so far, at every nesting level. * @@ -325,6 +301,10 @@ protected function automatedList(int $index): array { 'field_c_p_list_filters_exp' => $this->option('civictheme_automated_list', 'field_c_p_list_filters_exp', $index), 'field_c_p_list_column_count' => $this->option('civictheme_automated_list', 'field_c_p_list_column_count', $index), 'field_c_p_list_fill_width' => CaseMatrix::bit($index, 1), + // Shares bit 0 rather than taking a high bit of its own: this component + // is itself placed on only a few nodes of a run, so a high bit turns on + // for a list that reaches a page too rarely to be worth looking at. + 'field_c_p_list_topics_from_page' => CaseMatrix::bit($index, 0), 'field_c_p_list_limit' => CaseMatrix::cycle([3, 6, 9], $index), 'field_c_p_list_link_above' => $this->link('View all'), 'field_c_p_list_link_below' => $this->link('See more'), diff --git a/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php b/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php index 7940feb7..b185f7d4 100644 --- a/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php +++ b/web/modules/custom/do_generated_content/src/Plugin/GeneratedContent/NodeBlog.php @@ -30,13 +30,7 @@ class NodeBlog extends NodeGeneratorBase { * {@inheritdoc} */ protected function bundleValues(int $index): array { - $values = $this->commonValues($index) + $this->bannerValues($index) + $this->pageValues($index); - - // Mirrors what the deploy hook puts on every real blog post, so generated - // content exercises the related list rather than only production does. - $values['field_c_n_components'][] = $this->generator()->relatedList(); - - return $values; + return $this->commonValues($index) + $this->bannerValues($index) + $this->pageValues($index); } } From 5c6b82763c6075ce262e3c8796b18e9535e4b8b7 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 11 Aug 2026 14:06:48 +1000 Subject: [PATCH 7/7] Addressed code review: asserted the topic alias and that the page excludes other topics. --- .../paragraph_civictheme_automated_list_related.feature | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/behat/features/paragraph_civictheme_automated_list_related.feature b/tests/behat/features/paragraph_civictheme_automated_list_related.feature index 14a199b5..745834c1 100644 --- a/tests/behat/features/paragraph_civictheme_automated_list_related.feature +++ b/tests/behat/features/paragraph_civictheme_automated_list_related.feature @@ -66,5 +66,8 @@ Feature: Automated list following the page's own topics Then I should see the link "[TEST] Shared Topic" When I click "[TEST] Shared Topic" - Then I should see "[TEST] Post Same Topic" + Then the path should be "/topics/test-shared-topic" + And I should see "[TEST] Post Same Topic" And I should see "[TEST] Post Being Read" + And I should not see "[TEST] Post Other Topic" + And I should not see "[TEST] Post Without Topic"