Skip to content

Annotations — Developer Reference

Developer-focused reference. For module overview, entity capabilities, and the permission model see README.md.


Config management

Scope config (annotation_target, annotation_type) is managed via standard config sync. Annotation text lives in the database and is never touched by config management tools.


Drush commands

Inspection (root annotations module)

Commands for querying the live state of targets, types, and annotation content. No submodules required.

# List all annotation_target config entities (field count + live annotation count)
drush ann:targets
drush ann:targets node            # filter by entity type
drush ann:targets --format=json

# List all annotation_type config entities (sorted by weight)
drush ann:types
drush ann:types --format=json

# Show stored annotation content
drush ann:show                              # all annotations (default limit 50)
drush ann:show node__article               # single target
drush ann:show --entity-type=node          # all node targets
drush ann:show node__article --type=editorial
drush ann:show node__article --field=body  # specific field
drush ann:show node__article --field=      # bundle-level only
drush ann:show --limit=200 --format=json

# Coverage stats: annotation counts per target broken down by type
drush ann:stats
drush ann:stats --entity-type=node
drush ann:stats --format=yaml

All four commands return Drush's standard RowsOfFields structured output — --format accepts table (default), json, yaml, csv, and more; --fields narrows displayed columns on ann:targets/ann:types/ann:show (their column sets are static). ann:stats has no --fields support: its columns are the live set of annotation types, known only at runtime, so it can't declare a static #[CLI\FieldLabels] list.

Use drush list --filter=annotations to see all registered annotations commands. Use drush help ann:show for full option docs.

Audit scan (annotations_audit)

See modules/annotations_audit/README.md for ann:scan (--diff, --strict, --fields).

Export (annotations_export)

See modules/annotations_export/README.md for ann:ex (markdown and Obsidian vault export).


Developer API

AnnotationStorageService (annotations.annotation_storage)

Central service for all annotation CRUD. Inject annotations.annotation_storage.

use Drupal\annotations\AnnotationStorageService;

// Load all annotations for a target.
// Returns: array<field_name, array<type_id, value>>
// Bundle-level annotations use '' as field_name.
$all = $annotationStorage->getForTarget('node__article');

// Bundle-level overview annotation.
$editorial = $all['']['editorial'] ?? '';

// Field-level annotation.
$body_technical = $all['body']['technical'] ?? '';

// Save site-wide annotations.
$annotationStorage->saveSiteAnnotations(['site_purpose' => '...']);

// Check whether a target has any annotation data.
$hasData = $annotationStorage->hasAnnotationData('node__article');

// Delete all annotation data for a target.
$annotationStorage->deleteForTarget('node__article');

Target plugin manager (plugin.manager.annotations_target)

Standard Drupal plugin manager (TargetPluginManager) for Target plugins, discovered from src/Plugin/AnnotationsTarget of every enabled module via the #[AnnotationsTarget] attribute. GenericTarget uses a deriver to cover every fieldable entity type not claimed by a dedicated plugin. Inject plugin.manager.annotations_target (type-hint TargetPluginManagerInterface).

$plugins = $targetPluginManager->getPlugins();
// Returns: array<entity_type_id, TargetInterface>
// Dedicated plugins shadow generic derivatives for the same entity type.

foreach ($plugins as $entity_type_id => $plugin) {
  if (!$plugin->isAvailable()) continue;
  $label   = $plugin->getLabel();   // e.g. "Content types"
  $bundles = $plugin->getBundles(); // array<bundle_key, label>
  $hasFields = $plugin->hasFields();
}

Extending annotation types with custom behaviors

AnnotationType implements ThirdPartySettingsInterface. See annotations_type_ui/DEVELOPING.md for the full pattern.


Adding a custom Target plugin

The default TargetBase::discover() includes FieldConfig fields and the four common editorial base fields (title, body, name, description). If you need to surface additional base fields — for example, a contrib entity type that uses a different machine name for its main content field — override discover() in a custom plugin.

Place a class in src/Plugin/AnnotationsTarget of your module with the #[AnnotationsTarget] attribute and extend TargetBase (which implements TargetInterface and provides DI for the entity type manager, bundle info, and field manager):

// mymodule/src/Plugin/AnnotationsTarget/CustomTarget.php
namespace Drupal\mymodule\Plugin\AnnotationsTarget;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\annotations\Attribute\AnnotationsTarget;
use Drupal\annotations\Plugin\AnnotationsTarget\TargetBase;

#[AnnotationsTarget(
  id: 'my_entity', // Plugin ID doubles as the entity type ID.
  label: new TranslatableMarkup('My entities'),
)]
class CustomTarget extends TargetBase {
  // getEntityTypeId(), getLabel(), isAvailable(), getBundles(), hasFields(),
  // and discover() all have working defaults in TargetBase — override only
  // what needs special handling.
}

No service registration and no changes to the annotations module are needed — attribute discovery picks the plugin up automatically. A dedicated plugin for a fieldable entity type shadows the auto-derived generic:{entity_type_id} derivative for that type. Definitions can be altered via hook_annotations_target_info_alter().


Shipping default annotations

Use the drupal script to export annotation entities as files in a recipe's content/ directory. This is the mechanism for shipping a starter annotation set with a profile or recipe.

ddev php web/core/scripts/drupal content:export annotation --dir=recipes/myrecipe/content

Recipe authoring

The root module ships two config action plugins for wiring up annotation scope in a recipe without overwriting pre-existing config.

enableTargetType

Appends one or more entity types to annotations.target_types. Idempotent — types already in the list are skipped.

config:
  actions:
    annotations.target_types:
      enableTargetType: node
      # or a list:
      # enableTargetType:
      #   - node
      #   - taxonomy_term

enableTargetField

Appends fields to an annotation_target entity's fields list. Idempotent — fields already registered are skipped. If the target does not yet exist it is created automatically, with its label sourced from Drupal's bundle info (e.g. node.type.article → "Article").

config:
  actions:
    annotations.target.node__article:
      enableTargetField:
        - title
        - body
        - field_tags

Bolt-on recipe pattern (targeting existing content types)

install:
  - annotations
  - annotations_ui
  - annotations_type_ui

config:
  actions:
    annotations.target_types:
      enableTargetType: node
    annotations.target.node__article:
      enableTargetField:
        - title
        - body
        - field_tags

New content type recipe pattern

When a recipe creates its own content types, ship annotations.target.{entity_type}__{bundle}.yml directly in the recipe's config/ directory alongside the node type, field, and display configs. Config import runs before config actions, so the target entity is already in place by the time any action fires. enableTargetField is not needed — the target YAML already carries the full fields list.

See recipes/annotations_demo/ for a worked example.


Documentation site

Published at https://project.pages.drupalcode.org/annotations/ via GitLab Pages (MkDocs Material). Source files are the README.md and DEVELOPING.md files that live alongside the code in each module and recipe directory — no separate docs tree to maintain.

To preview locally, mirror the files into docs/ then serve:

pip install mkdocs-material
mkdir -p docs docs/recipes
cp README.md DEVELOPING.md docs/
for dir in modules/annotations_*/; do mkdir -p "docs/$dir"; [ -f "$dir/README.md" ] && cp "$dir/README.md" "docs/$dir/"; [ -f "$dir/DEVELOPING.md" ] && cp "$dir/DEVELOPING.md" "docs/$dir/"; done
cp recipes/README.md docs/recipes/ && for dir in recipes/annotations_*/; do mkdir -p "docs/$dir" && cp "$dir/README.md" "docs/$dir/"; done
mkdocs serve

docs/ is not committed — CI creates it from the source files on every build.


Notes

Views listed use a text variable instead of value in the filter criteria because that was a reserved name: admin/structure/views/view/annotations_target, admin/structure/views/view/annotations