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..9f63cf49 --- /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: false +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..d7dd5d90 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: 1 + 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_snippet 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..745834c1 --- /dev/null +++ b/tests/behat/features/paragraph_civictheme_automated_list_related.feature @@ -0,0 +1,73 @@ +@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 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" 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/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php index 02b5b30b..38fae103 100644 --- a/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php +++ b/web/modules/custom/do_generated_content/src/Generator/ComponentGenerator.php @@ -301,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/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..8dca5274 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,15 +38,24 @@ function _drevops_node_add_topic_tags(NodeInterface $node, array &$variables): v return; } - $topics = civictheme_get_referenced_entity_labels($node, 'field_c_n_topics', $variables); + $terms = civictheme_get_field_referenced_entities($node, 'field_c_n_topics', $variables); - if (empty($topics)) { + if (empty($terms)) { return; } + $tags = []; + + foreach ($terms as $term) { + $tags[] = [ + 'content' => Xss::filter((string) $term->label()), + 'url' => $term->toUrl()->toString(), + ]; + } + $variables['content'][] = [ '#theme' => 'civictheme_tag_list', - '#tags' => array_map(static fn($label): array => ['content' => (string) $label], $topics), + '#tags' => $tags, '#vertical_spacing' => 'both', '#weight' => 100, ];