Skip to content

[#256] Migrated blog articles onto a dedicated 'blog' content type. - #269

Merged
AlexSkrypnyk merged 11 commits into
developfrom
feature/256-blog-content-type
Jul 30, 2026
Merged

[#256] Migrated blog articles onto a dedicated 'blog' content type.#269
AlexSkrypnyk merged 11 commits into
developfrom
feature/256-blog-content-type

Conversation

@AlexSkrypnyk

@AlexSkrypnyk AlexSkrypnyk commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes #256

Checklist before requesting a review

  • Subject includes ticket number as [#123] Verb in past tense.
  • Ticket number #123 added to description
  • Added context in Changed section
  • Self-reviewed code and commented in commented complex areas.
  • Added tests for fix/feature.
  • Relevant tests run and passed locally.

Changed

  1. Added the blog content type, labelled "Blog post", cloned from civictheme_page so the two bundles stay interchangeable - same moderation states, same available menus, same revision and submitted-by behaviour.
  2. Attached all 21 existing node field instances to the new bundle. Every field a blog article uses is a node-level field storage already shared with civictheme_page, so this attaches instances rather than creating storages. That is what makes the migration a bundle change in place: no field data is copied or transformed.
  3. Added the form display as a copy of the Page form, with one deliberate difference: the Components widget opens on a pre-added Content paragraph (default_paragraph_type: civictheme_content, edit_mode: open), so a new post opens ready to type into instead of starting empty.
  4. Added the four view displays (default, civictheme_promo_card, civictheme_navigation_card, civictheme_slider_slide), byte-identical to the civictheme_page ones apart from their UUID. The default display is Layout Builder-driven, so the bundle embedded in each block plugin ID was rewritten too; the layout keeps the same three rendered blocks the Page layout has, rather than the thirteen that Layout Builder would have produced had the display been saved through the entity API.
  5. Added the supporting configuration: a pathauto pattern of /blog/[node:title], membership of the civictheme_editorial workflow, xmlsitemap bundle settings, metatag defaults, and language content settings.
  6. Granted the blog equivalent of every civictheme_page permission already held by the Content Author and Site Administrator roles. The Content Approver role holds no civictheme_page permissions, so it gains none here.
  7. Added a blog / "Blog post" option to the field_c_p_list_content_type allowed values, leaving the existing options and their order intact. The automated list view already takes the bundle as a contextual argument and its exposed content-type filter is unrestricted, so the view itself needed no change.
  8. Added two sandbox-batched deploy hooks to do_base.deploy.php, do_base_deploy_migrate_blog_posts() and do_base_deploy_repoint_blog_lists(), covered in detail below. Every supporting function is private and underscore-prefixed, so only the two hooks are part of the module's surface.
  9. Added _civictheme_preprocess_node__blog__full() in a dedicated theme include, web/themes/custom/drevops/includes/blog.inc. CivicTheme resolves full-view node preprocessing by bundle name, so each content type owns its own theming and a bundle CivicTheme does not ship renders without a table of contents, without topic tags, and with the node title left in place on revision pages. Blog posts now supply that per-bundle function themselves, keeping the type's theming self-contained and independently changeable, and leaving the theme's generic view-mode hook free for cross-bundle work. Configuration parity alone did not cover this - it lives in the theme layer.
  10. Added Behat coverage - fourteen scenarios in tests/behat/features/blog_content_type.feature over the authoring form, the pre-added Content paragraph, the /blog alias, the editorial states, draft visibility, component rendering, topic tags (both shown and hidden), the table of contents (both on and off), the automated list option, and a list set to Blog post returning only blog posts. One asserts the Page form still opens with no paragraph pre-added, so the Page type is provably unchanged. A fifteenth scenario in tests/behat/features/xmlsitemap.feature asserts a published blog post reaches /sitemap.xml at its /blog alias while a draft does not.
  11. Added docs/development.md, recording the project's hook agreements so they are not rediscovered each time: hooks are the only public functions and every helper is private and underscore-prefixed; drupal_helpers is reached for before hand-rolling, with its README named as the authoritative list of what exists; and the mechanics that follow from batching, namely the nullable sandbox parameter, one batched operation per hook, batch sizing, per-item atomicity, reporting, and the bootstrap an update hook needs. The long drupal_helpers rules in AGENTS.md now point here rather than restating it.
  12. Added docs/content-types.md, capturing the approach so the next custom content type does not have to rediscover it: the full configuration surface a node bundle needs, the two traps when cloning displays programmatically (Layout Builder rewriting a display saved through the entity API, and the simplified runtime shape of list_string allowed values), the per-bundle theme preprocessor that configuration parity does not cover, and how to move existing nodes onto a new bundle. Linked from docs/README.md and AGENTS.md.
  13. Documented blog nodes in the sitemap coverage table in docs/sitemap.md.

How the migration works

Both hooks batch through Helper::entity($sandbox)->batchQuery(), so the work spreads across requests instead of risking a timeout. They are two hooks rather than one because a sandbox uses fixed keys and cannot carry two batched operations; deploy hooks run alphabetically, so the names put the migration before the repointing. The node hook uses a batch size of 10 because each article rewrites rows in more than forty field tables.

The migration changes each article's bundle in storage rather than through the entity API. Drupal treats a loaded entity's bundle as immutable, and the only entity-API route - delete and recreate - would discard exactly what the acceptance criteria require to survive: the node IDs, the 242 revisions, and the paragraph references. So it updates node.type, node_field_data.type, and the bundle column of every dedicated field table. That table list is derived from the entity table mapping at runtime rather than naming 42 tables literally, so a field attached to either bundle later cannot be silently missed.

Each article commits or rolls back on its own rather than the run sharing one transaction. A sandbox spans several requests, so a single transaction could not wrap it anyway, and per-item atomicity combined with a query that only matches unmigrated articles is what makes an interrupted batch safe to resume. The cost is that the two hooks are not atomic with each other: if the repointing fails, the lists render empty until drush deploy:hook runs again, which completes them.

Because no node is ever re-saved, pathauto never fires, and every hand-crafted alias survives verbatim - including /vortex-138-released, which sits outside /blog/ and would otherwise have been rewritten by the new pattern.

Storage-level updates fire no entity hooks, so the hook explicitly repairs the three things that would otherwise desync: the xmlsitemap link subtype (the existing rebuild hook runs only once per environment and will not re-run), search index tracking, and the node and list cache tags.

The second hook repoints the two automated lists, scoped by the Blog topic rather than by target bundle, so the case studies list - which targets Pages under a different topic - is left alone. The repoint is applied across every revision of those paragraphs rather than by saving the entity: a host node references one specific paragraph revision, which is not necessarily the paragraph's current one, so saving would have left the host still rendering the old bundle. Older revisions want the new value too - left on civictheme_page they would render an empty list once no page carries the Blog topic any more.

Idempotency comes from the queries themselves: they match civictheme_page nodes carrying the Blog topic, and lists still pointing at civictheme_page. After a successful run neither matches, and the hook reports that there is nothing to migrate and nothing to repoint.

Verified against restored production content

Run locally against a restored database: 24 articles migrated across 242 revisions in three batch passes, with every node ID, alias, revision count and moderation state asserted unchanged one by one; the archived article still archived and still returning 403 to anonymous users; zero field rows left on the old bundle; the /blog pager reaching all 23 published articles with no duplicates; the homepage teasers and the case studies list intact; and a second run of both hooks reporting nothing to process.

Topic tags and the table of contents were confirmed on a rendered article, and the two new positive scenarios were checked against a build with the preprocessor removed to confirm they fail without it rather than passing vacuously.

Screenshots

The /blog listing after the migration, showing 23 published blog posts as promo cards in three columns

Before / After

BEFORE                                          AFTER

┌──────────────────────────────┐                ┌──────────────────────────────┐  ┌──────────────────────────────┐
│  civictheme_page             │                │  civictheme_page             │  │  blog  ("Blog post")         │
├──────────────────────────────┤                ├──────────────────────────────┤  ├──────────────────────────────┤
│  /about-us                   │                │  /about-us                   │  │  /blog/meet-vortex           │
│  /services                   │                │  /services                   │  │  /blog/vortex-134            │
│  /case-studies               │                │  /case-studies               │  │  ... 24 articles             │
│  /blog          (landing)    │                │  /blog          (landing)    │  │  /vortex-138-released        │
├──────────────────────────────┤                └──────────────────────────────┘  ├──────────────────────────────┤
│  /blog/meet-vortex        ┐  │                                                  │  same nids, same vids,       │
│  /blog/vortex-134         │  │ articles,                                        │  same aliases, same 242      │
│  ... 24 articles          │  │ told apart                                       │  revisions, same states      │
│  /vortex-138-released     ┘  │ only by a                                        └──────────────────────────────┘
└──────────────────────────────┘ topic tag                                          bundle changed in place


AUTOMATED LISTS

  BEFORE                                          AFTER

  /blog landing                                   /blog landing
    content_type: civictheme_page                   content_type: blog          <- repointed
    topic:        Blog                              topic:        Blog

  homepage "Recent blog posts"                    homepage "Recent blog posts"
    content_type: civictheme_page                   content_type: blog          <- repointed
    topic:        Blog                              topic:        Blog

  /case-studies                                   /case-studies
    content_type: civictheme_page                   content_type: civictheme_page
    topic:        Case study                        topic:        Case study    <- untouched

Summary by CodeRabbit

  • New Features
    • Added a Blog post content type with configurable banners, summaries, thumbnails, topics, components, table of contents, tags, and sidebar options.
    • Added blog-specific layouts for standard pages, navigation cards, promotional cards, and slider slides.
    • Added editorial moderation states, blog URL aliases, sitemap support, metatags, and automated-list filtering.
    • Added blog migration support for existing content and lists.
  • Documentation
    • Added guidance for creating, deploying, and developing custom content types.
  • Tests
    • Added coverage for blog creation, publishing, rendering, permissions, URLs, and sitemap behavior.

…current one.

A host node references one specific paragraph revision, which is not necessarily the paragraph's current one, so saving the paragraph left the host still rendering the old bundle. Setting the value across every revision also keeps older node revisions rendering a populated list.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Blog content type

Layer / File(s) Summary
Blog content model and supporting configuration
config/default/node.type.blog.yml, config/default/field.field.node.blog.*, config/default/pathauto.pattern.blog.yml, config/default/metatag.metatag_defaults.node__blog.yml
Adds the Blog post bundle, fields, language and metatag settings, /blog/[node:title] aliases, and automated-list selection support.
Blog authoring and rendering displays
config/default/core.entity_form_display.node.blog.default.yml, config/default/core.entity_view_display.node.blog.*
Configures the tabbed edit form, Layout Builder default display, and card/slider view modes.
Workflow, access, sitemap, and list integration
config/default/user.role.*.yml, config/default/workflows.workflow.civictheme_editorial.yml, config/default/xmlsitemap.settings.node.blog.yml, docs/sitemap.md
Adds blog permissions, editorial workflow coverage, and sitemap settings/documentation.
Blog content migration and development guidance
web/modules/custom/do_base/do_base.deploy.php, docs/content-types.md, docs/development.md, AGENTS.md, docs/README.md
Adds transactional node migration, automated-list repointing, cache/search updates, and related implementation guidance.
Blog full-view rendering
web/themes/custom/drevops/drevops.theme, web/themes/custom/drevops/includes/blog.inc
Adds bundle-specific preprocessing for optional table-of-contents and topic-tag output.
Blog authoring, rendering, and sitemap coverage
tests/behat/features/blog_content_type.feature, tests/behat/features/xmlsitemap.feature
Adds end-to-end checks for authoring, moderation, aliases, access, rendering, automated lists, and sitemap inclusion.
Navigation logo provider
config/default/navigation.settings.yml
Changes the navigation logo provider to hide.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DeployHook
  participant DrupalStorage
  participant SearchAPI
  participant Cache
  DeployHook->>DrupalStorage: Find Blog-topic pages and automated lists
  DeployHook->>DrupalStorage: Migrate nodes and repoint paragraph targets
  DeployHook->>SearchAPI: Track migrated node items
  DeployHook->>Cache: Reset caches and invalidate cache tags
Loading

Suggested labels: A3

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The navigation.settings.yml logo-provider change is unrelated to the blog content-type migration objectives. Remove the logo-provider tweak or justify it with a clear blog-migration requirement.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The config, deploy hook, theme, docs, and Behat updates align with the blog content-type migration requirements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: migrating blog articles to a dedicated blog content type.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/256-blog-content-type

Comment @coderabbitai help to get the list of available commands.

@AlexSkrypnyk AlexSkrypnyk added A1 Board worker 1 AUTOMERGE Pull request has been approved and set to automerge labels Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/default/core.entity_view_display.node.blog.civictheme_promo_card.yml`:
- Around line 53-58: Update field_c_n_thumbnail in both
config/default/core.entity_view_display.node.blog.civictheme_promo_card.yml
(lines 53-58) and
config/default/core.entity_view_display.node.blog.civictheme_navigation_card.yml
(lines 53-58) to use the entity_reference_entity_view formatter, configuring the
media view mode used by the slider display instead of rendering the media
label/link.

In `@config/default/field.field.node.blog.field_c_n_banner_blend_mode.yml`:
- Line 14: Update the field label value in the configuration to use a single
space between “blend” and “mode,” preserving the rest of the label unchanged.

In `@web/modules/custom/do_base/do_base.deploy.php`:
- Around line 425-430: Extend the bundle migration alongside the existing node
and node_field_data updates to also update node_field_revision.type from
civictheme_page to blog for the affected nids, before or alongside the related
table updates. Keep the existing conditions and blog conversion flow unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 786a41a5-ba9b-46a8-9edf-1a879edaaa33

📥 Commits

Reviewing files that changed from the base of the PR and between 441f099 and bacdf5d.

📒 Files selected for processing (38)
  • config/default/core.entity_form_display.node.blog.default.yml
  • config/default/core.entity_view_display.node.blog.civictheme_navigation_card.yml
  • config/default/core.entity_view_display.node.blog.civictheme_promo_card.yml
  • config/default/core.entity_view_display.node.blog.civictheme_slider_slide.yml
  • config/default/core.entity_view_display.node.blog.default.yml
  • config/default/field.field.node.blog.field_c_n_banner_background.yml
  • config/default/field.field.node.blog.field_c_n_banner_blend_mode.yml
  • config/default/field.field.node.blog.field_c_n_banner_components.yml
  • config/default/field.field.node.blog.field_c_n_banner_components_bott.yml
  • config/default/field.field.node.blog.field_c_n_banner_featured_image.yml
  • config/default/field.field.node.blog.field_c_n_banner_hide_breadcrumb.yml
  • config/default/field.field.node.blog.field_c_n_banner_theme.yml
  • config/default/field.field.node.blog.field_c_n_banner_title.yml
  • config/default/field.field.node.blog.field_c_n_banner_type.yml
  • config/default/field.field.node.blog.field_c_n_components.yml
  • config/default/field.field.node.blog.field_c_n_custom_last_updated.yml
  • config/default/field.field.node.blog.field_c_n_hide_sidebar.yml
  • config/default/field.field.node.blog.field_c_n_hide_tags.yml
  • config/default/field.field.node.blog.field_c_n_show_last_updated.yml
  • config/default/field.field.node.blog.field_c_n_show_toc.yml
  • config/default/field.field.node.blog.field_c_n_site_section.yml
  • config/default/field.field.node.blog.field_c_n_summary.yml
  • config/default/field.field.node.blog.field_c_n_thumbnail.yml
  • config/default/field.field.node.blog.field_c_n_topics.yml
  • config/default/field.field.node.blog.field_c_n_vertical_spacing.yml
  • config/default/field.field.node.blog.field_n_metatags.yml
  • config/default/field.storage.paragraph.field_c_p_list_content_type.yml
  • config/default/language.content_settings.node.blog.yml
  • config/default/metatag.metatag_defaults.node__blog.yml
  • config/default/node.type.blog.yml
  • config/default/pathauto.pattern.blog.yml
  • config/default/user.role.civictheme_content_author.yml
  • config/default/user.role.civictheme_site_administrator.yml
  • config/default/workflows.workflow.civictheme_editorial.yml
  • config/default/xmlsitemap.settings.node.blog.yml
  • docs/sitemap.md
  • tests/behat/features/blog_content_type.feature
  • web/modules/custom/do_base/do_base.deploy.php

Comment thread web/modules/custom/do_base/do_base.deploy.php Outdated
@github-actions

This comment has been minimized.

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.16%. Comparing base (441f099) to head (568cd7d).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop     #269   +/-   ##
========================================
  Coverage    89.16%   89.16%           
========================================
  Files           15       15           
  Lines          203      203           
========================================
  Hits           181      181           
  Misses          22       22           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@AlexSkrypnyk AlexSkrypnyk added the Needs review Pull request needs a review from assigned developers label Jul 29, 2026
@AlexSkrypnyk
AlexSkrypnyk temporarily deployed to PR-269 July 29, 2026 12:12 Inactive
CivicTheme dispatches its full-view node preprocessing by bundle name, so a bundle it does not ship rendered without a table of contents, without topic tags, and with the title left in place on revision pages. Blog posts now reuse the page preprocessor rather than restating it.
@github-actions

This comment has been minimized.

@AlexSkrypnyk
AlexSkrypnyk temporarily deployed to PR-269 July 30, 2026 00:00 Inactive
…sor.

CivicTheme resolves full-view node preprocessing by bundle name so that each content type owns its own theming. Blog posts now supply that per-bundle function in a dedicated include, leaving the theme's generic view-mode hook free for cross-bundle work.
@github-actions

This comment has been minimized.

@AlexSkrypnyk
AlexSkrypnyk temporarily deployed to PR-269 July 30, 2026 00:18 Inactive
Captures the configuration a node bundle needs, the two traps when cloning displays programmatically, the per-bundle theme preprocessor that configuration parity does not cover, and how to move existing nodes onto a new bundle.
@github-actions

This comment has been minimized.

@AlexSkrypnyk
AlexSkrypnyk temporarily deployed to PR-269 July 30, 2026 00:35 Inactive
…ts helpers private.

Splits the work into two sandbox-batched deploy hooks, since one sandbox cannot carry two batched operations, and moves atomicity to each item so an interrupted batch is safe to resume. Adds sitemap coverage for blog posts and records the hook agreements in docs/development.md.
@github-actions

Copy link
Copy Markdown

Code coverage (threshold: 80%)

  Classes: 50.00% (7/14)
  Methods: 73.33% (22/30)
  Lines:   93.21% (357/383)
Per-class coverage
Drupal\do_content_api\EventSubscriber\JsonApiWriteGateSubscriber
  Methods:  66.67% ( 2/ 3)   Lines:  88.89% (  8/  9)
Drupal\do_content_api\Hook\EntityCreateAccessHook
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  9/  9)
Drupal\do_content_api\Hook\ModerationPolicyHook
  Methods:  50.00% ( 1/ 2)   Lines:  93.75% ( 15/ 16)
Drupal\do_content_api\Routing\RouteSubscriber
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  6/  6)
Drupal\do_feed\FeedUrlBuilder
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 18/ 18)
Drupal\do_feed\Form\FeedSettingsForm
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 15/ 15)
Drupal\do_feed\Hook\EntityDeleteHook
  Methods:  50.00% ( 1/ 2)   Lines:  92.31% ( 12/ 13)
Drupal\do_feed\Hook\EntityPresaveHook
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 54/ 54)
Drupal\do_feed\Hook\PreprocessParagraphHook
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% ( 14/ 14)
Drupal\do_feed\Hook\PreprocessViewsViewRowRssHook
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  3/  3)
Drupal\do_feed\Hook\ViewsPreViewHook
  Methods:  50.00% ( 1/ 2)   Lines:  96.43% ( 27/ 28)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/modules/custom/do_base/do_base.deploy.php`:
- Around line 398-400: Update the node query near lines 398-400 and the
paragraph query near lines 429-432 in
web/modules/custom/do_base/do_base.deploy.php to explicitly disable access
checking with accessCheck(FALSE). Update the corresponding deploy-hook example
at docs/development.md lines 33-38 to include the same explicit access policy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: beadd209-93c2-4b4a-8a18-d5348327e179

📥 Commits

Reviewing files that changed from the base of the PR and between 1fc3a14 and 568cd7d.

📒 Files selected for processing (10)
  • AGENTS.md
  • config/default/core.entity_form_display.node.blog.default.yml
  • config/default/navigation.settings.yml
  • docs/README.md
  • docs/content-types.md
  • docs/development.md
  • tests/behat/features/xmlsitemap.feature
  • web/modules/custom/do_base/do_base.deploy.php
  • web/themes/custom/drevops/drevops.theme
  • web/themes/custom/drevops/includes/blog.inc

Comment thread web/modules/custom/do_base/do_base.deploy.php
@AlexSkrypnyk
AlexSkrypnyk merged commit 790a398 into develop Jul 30, 2026
10 checks passed
@AlexSkrypnyk
AlexSkrypnyk deleted the feature/256-blog-content-type branch July 30, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A1 Board worker 1 AUTOMERGE Pull request has been approved and set to automerge Needs review Pull request needs a review from assigned developers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate blog articles onto a dedicated Blog post content type

2 participants