Skip to content

Instantly share code, notes, and snippets.

@fago
Created August 10, 2026 19:54
Show Gist options
  • Select an option

  • Save fago/6553332708f0442a3abf2aaa67fc2a82 to your computer and use it in GitHub Desktop.

Select an option

Save fago/6553332708f0442a3abf2aaa67fc2a82 to your computer and use it in GitHub Desktop.
ce_address_plain CE field formatter (custom_elements_extra_formatters): address field -> locale-ordered multi-line plain text via the commerceguys/addressing formatter (html=false). MCD-1177 / drupal.org MR !194.
diff --git a/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php b/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php
new file mode 100644
index 0000000..4e8ea19
--- /dev/null
+++ b/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Drupal\custom_elements_extra_formatters\Plugin\CustomElementsFieldFormatter;
+
+use CommerceGuys\Addressing\Formatter\DefaultFormatter;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\custom_elements\CustomElement;
+use Drupal\custom_elements\CustomElementsFieldFormatterBase;
+use Drupal\custom_elements\CustomElementsFieldFormatterUtilsTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * CE field formatter for address, emitting a multi-line plain-text address.
+ *
+ * The address module's formatters are HTML-first: 'address_default' escapes
+ * every component, wraps it in a span and the whole address in a <p>, while
+ * 'address_plain' renders a template with a fixed, non locale-aware field
+ * order. A decoupled frontend needs neither.
+ *
+ * The underlying commerceguys/addressing library formats an address by
+ * substituting the components into the country's format string; the HTML
+ * wrapping and escaping is only applied afterwards, and only when its 'html'
+ * option is enabled. So this formatter uses that library formatter with 'html'
+ * disabled, which yields the country-specific component order with one address
+ * line per newline and no markup to strip.
+ *
+ * The frontend renders that directly (e.g. white-space: pre-line) and collapses
+ * the newlines where it needs a one-liner (map marker, calendar entry).
+ *
+ * The plugin only applies to address fields, so it stays inert - and the
+ * address module optional - when that field type is not available.
+ *
+ * @CustomElementsFieldFormatter(
+ * id = "ce_address_plain",
+ * label = @Translation("Address (plain text)"),
+ * field_types = {
+ * "address"
+ * },
+ * weight = -10
+ * )
+ */
+class AddressPlainTextCeFormatter extends CustomElementsFieldFormatterBase {
+
+ use CustomElementsFieldFormatterUtilsTrait;
+
+ /**
+ * The address format repository.
+ *
+ * @var \CommerceGuys\Addressing\AddressFormat\AddressFormatRepositoryInterface
+ */
+ protected $addressFormatRepository;
+
+ /**
+ * The country repository.
+ *
+ * @var \CommerceGuys\Addressing\Country\CountryRepositoryInterface
+ */
+ protected $countryRepository;
+
+ /**
+ * The subdivision repository.
+ *
+ * @var \CommerceGuys\Addressing\Subdivision\SubdivisionRepositoryInterface
+ */
+ protected $subdivisionRepository;
+
+ /**
+ * The addressing library's address formatter.
+ *
+ * @var \CommerceGuys\Addressing\Formatter\FormatterInterface
+ */
+ protected $addressFormatter;
+
+ /**
+ * The language manager.
+ *
+ * @var \Drupal\Core\Language\LanguageManagerInterface
+ */
+ protected $languageManager;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ // @phpstan-ignore-next-line
+ $instance = new static($configuration, $plugin_id, $plugin_definition);
+ $instance->languageManager = $container->get('language_manager');
+ // The address module is an optional dependency, so the plugin must stay
+ // instantiable without it. It never applies to any field then, since the
+ // address field type does not exist either.
+ if ($container->has('address.address_format_repository')) {
+ $instance->addressFormatRepository = $container->get('address.address_format_repository');
+ $instance->countryRepository = $container->get('address.country_repository');
+ $instance->subdivisionRepository = $container->get('address.subdivision_repository');
+ }
+ return $instance;
+ }
+
+ /**
+ * Gets the addressing library's address formatter.
+ *
+ * The address module ships the library and services for its repositories,
+ * but exposes no service for the formatter itself.
+ *
+ * @return \CommerceGuys\Addressing\Formatter\FormatterInterface
+ * The address formatter.
+ */
+ protected function getAddressFormatter() {
+ if (!isset($this->addressFormatter)) {
+ $this->addressFormatter = new DefaultFormatter(
+ $this->addressFormatRepository,
+ $this->countryRepository,
+ $this->subdivisionRepository
+ );
+ }
+ return $this->addressFormatter;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(string $plugin_id, FieldDefinitionInterface $field_definition) {
+ return $field_definition->getType() === 'address';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build(FieldItemListInterface $items, CustomElement $custom_element, $langcode = NULL) {
+ if (empty($langcode)) {
+ $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();
+ }
+ $values = [];
+ foreach ($items as $item) {
+ if ($item->isEmpty()) {
+ continue;
+ }
+ // The country name is localized to $langcode, the component order to the
+ // address' own locale.
+ $values[] = $this->getAddressFormatter()->format($item, [
+ 'locale' => $langcode,
+ 'html' => FALSE,
+ ]);
+ }
+ if (!$values) {
+ return;
+ }
+ // The localized country name varies by interface and content language.
+ $custom_element->addCacheContexts([
+ 'languages:' . LanguageInterface::TYPE_INTERFACE,
+ 'languages:' . LanguageInterface::TYPE_CONTENT,
+ ]);
+ $this->setMultipleValue($custom_element, $this->getName(), $values);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+ }
+
+}
diff --git a/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php
new file mode 100644
index 0000000..3c1540b
--- /dev/null
+++ b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\Tests\custom_elements_extra_formatters\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\custom_elements\CustomElementGeneratorTrait;
+use Drupal\custom_elements\Entity\EntityCeDisplay;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * Tests ce_address_plain renders addresses as multi-line plain text.
+ *
+ * @group custom_elements
+ */
+class AddressPlainTextCeFormatterTest extends KernelTestBase {
+
+ use CustomElementGeneratorTrait;
+ use NodeCreationTrait;
+ use UserCreationTrait;
+
+ /**
+ * {@inheritdoc}
+ *
+ * @var bool
+ */
+ protected $strictConfigSchema = TRUE;
+
+ /**
+ * {@inheritdoc}
+ *
+ * @var string[]
+ */
+ protected static $modules = [
+ 'custom_elements',
+ 'custom_elements_extra_formatters',
+ 'field',
+ 'address',
+ 'user',
+ 'node',
+ 'text',
+ 'system',
+ 'filter',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('node');
+ $this->installConfig('node');
+ NodeType::create([
+ 'type' => 'event',
+ 'name' => 'Event',
+ ])->save();
+
+ FieldStorageConfig::create([
+ 'field_name' => 'field_address_test',
+ 'type' => 'address',
+ 'entity_type' => 'node',
+ 'cardinality' => 1,
+ ])->save();
+ FieldConfig::create([
+ 'field_name' => 'field_address_test',
+ 'entity_type' => 'node',
+ 'bundle' => 'event',
+ 'label' => 'Address',
+ ])->save();
+
+ EntityCeDisplay::create([
+ 'targetEntityType' => 'node',
+ 'customElementName' => 'node',
+ 'bundle' => 'event',
+ 'mode' => 'full',
+ ])
+ ->setComponent('address-plain', [
+ 'field_name' => 'field_address_test',
+ 'is_slot' => FALSE,
+ 'formatter' => 'ce_address_plain',
+ ])
+ ->save();
+ }
+
+ /**
+ * Addresses are emitted as markup-free, locale-ordered multi-line text.
+ */
+ public function testPlainTextAddress() {
+ // The Czech format puts the postal code before the locality, and the
+ // organization above the street - the fixed order of the address module's
+ // 'address_plain' template would get both wrong. The ampersand verifies
+ // no HTML escaping leaks into the plain-text output.
+ $node = $this->createNode([
+ 'title' => 'Czech event',
+ 'type' => 'event',
+ 'field_address_test' => [
+ 'country_code' => 'CZ',
+ 'organization' => 'Novák & Sons',
+ 'address_line1' => '5. května 1640/65',
+ 'locality' => 'Praha 4',
+ 'postal_code' => '140 21',
+ ],
+ ]);
+ $plain = $this->getCustomElementGenerator()->generate($node, 'full')->getAttribute('address-plain');
+ $this->assertSame("Novák & Sons\n5. května 1640/65\n140 21 Praha 4\nCzechia", $plain);
+
+ // A different country yields a different component order, proving the
+ // address format is applied rather than a hardcoded layout.
+ $node = $this->createNode([
+ 'title' => 'US event',
+ 'type' => 'event',
+ 'field_address_test' => [
+ 'country_code' => 'US',
+ 'address_line1' => '1098 Alta Ave',
+ 'locality' => 'Mountain View',
+ 'administrative_area' => 'CA',
+ 'postal_code' => '94043',
+ ],
+ ]);
+ $plain = $this->getCustomElementGenerator()->generate($node, 'full')->getAttribute('address-plain');
+ $this->assertSame("1098 Alta Ave\nMountain View, CA 94043\nUnited States", $plain);
+
+ // An empty address emits no attribute at all.
+ $node = $this->createNode([
+ 'title' => 'Event without address',
+ 'type' => 'event',
+ ]);
+ $element = $this->getCustomElementGenerator()->generate($node, 'full');
+ $this->assertNull($element->getAttribute('address-plain'));
+ }
+
+}
diff --git a/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php
new file mode 100644
index 0000000..3777d44
--- /dev/null
+++ b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\custom_elements_extra_formatters\Kernel;
+
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\custom_elements_extra_formatters\Plugin\CustomElementsFieldFormatter\AddressPlainTextCeFormatter;
+
+/**
+ * Tests ce_address_plain stays inert while the address module is not installed.
+ *
+ * @group custom_elements
+ */
+class AddressPlainTextCeFormatterWithoutAddressTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ *
+ * @var string[]
+ */
+ protected static $modules = [
+ 'custom_elements',
+ 'custom_elements_extra_formatters',
+ 'field',
+ 'user',
+ 'system',
+ ];
+
+ /**
+ * The plugin is discoverable and instantiable, but applies to nothing.
+ */
+ public function testPluginWithoutAddressModule() {
+ $this->assertFalse($this->container->has('address.address_format_repository'), 'The address module is not installed.');
+
+ /** @var \Drupal\custom_elements\CustomElementsFieldFormatterPluginManager $manager */
+ $manager = $this->container->get('custom_elements.plugin.manager.field.custom_element_formatter');
+ $this->assertArrayHasKey('ce_address_plain', $manager->getDefinitions());
+
+ // Instantiating must not fail on the missing address services.
+ $field_definition = BaseFieldDefinition::create('string')->setName('field_test');
+ $instance = $manager->createInstance('ce_address_plain', [
+ 'field_definition' => $field_definition,
+ 'view_mode' => 'full',
+ 'name' => 'address-plain',
+ 'is_slot' => FALSE,
+ ]);
+ $this->assertInstanceOf(AddressPlainTextCeFormatter::class, $instance);
+
+ // It declines every field, and is not offered for any field type, since
+ // the address field type does not exist without the address module.
+ $this->assertFalse(AddressPlainTextCeFormatter::isApplicable('ce_address_plain', $field_definition));
+ foreach ($manager->getOptions() as $field_type => $options) {
+ $this->assertArrayNotHasKey('ce_address_plain', $options, "Not offered for the $field_type field type.");
+ }
+ }
+
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment