From 0ae0d91c9525f46da1e1dad69bfa83acc0decf17 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Tue, 9 Jun 2026 14:21:07 +1000 Subject: [PATCH 1/2] feat: add Insert module integration for image and file fields Implements hook_insert_styles() and hook_insert_render() in modules/insert.inc so custom formatters targeting image, file, or entity_reference fields are exposed as Insert styles. InsertFieldItemList extends EntityReferenceFieldItemList (rather than FieldItemList) so that core formatters such as ImageFormatter, which type-hint against EntityReferenceFieldItemListInterface, work correctly when invoked via the FormatterPreset engine. --- CHANGELOG.md | 9 + README.md | 7 +- composer.json | 2 + modules/insert.inc | 142 ++++++++ src/InsertFieldItemList.php | 56 +++ .../Functional/CustomFormattersTestBase.php | 1 + .../src/Functional/InsertIntegrationTest.php | 342 ++++++++++++++++++ 7 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 modules/insert.inc create mode 100644 src/InsertFieldItemList.php create mode 100644 tests/src/Functional/InsertIntegrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1367dde..17cf6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 4.1.x-dev + +### Features + +- Added [Insert](https://www.drupal.org/project/insert) module integration — + custom formatters targeting `image`, `file`, or `entity_reference` fields + are automatically exposed as Insert styles, allowing formatted output to be + inserted directly into WYSIWYG editors. + ## 4.1.0-beta3 (2026-06-07) ### Features diff --git a/README.md b/README.md index 860804a..7c12856 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ Read the manual at: - **Devel Generate** _(optional)_ — Generates sample entities with dummy field data for the live preview system when no real entities exist with the target field type. + - **Insert** _(optional)_ — Exposes custom formatters as + [Insert](https://www.drupal.org/project/insert) styles for `image`, + `file`, and `entity_reference` fields, allowing formatted output to be + inserted directly into WYSIWYG editors. Activate by installing the Insert + module alongside Custom Formatters; no additional configuration required. ## Roadmap @@ -108,8 +113,6 @@ Planned features for future releases: against Drupal coding standards directly in the formatter edit form. - **Display Suite integration** — Format Display Suite fields with custom formatters. -- **Insert integration** — Expose custom formatters as Insert styles for - image and file fields. - **JSON:API integration** — Apply custom formatters to JSON:API field output. - **Field type management** — Change a formatter's field types after diff --git a/composer.json b/composer.json index f1d7e08..f07a531 100644 --- a/composer.json +++ b/composer.json @@ -22,12 +22,14 @@ }, "require-dev": { "drupal/devel": "^5", + "drupal/insert": "^3", "drupal/token": "^1" }, "suggest": { "drupal/codemirror_editor": "Provides syntax-highlighted code editing via CodeMirror for the PHP, HTML+Token, and Twig formatter engines.", "drupal/devel": "Provides debug output options for the preview feature and Devel Generate integration for generating sample preview content.", "drupal/field_tokens": "Provides field-level token support, useful for testing HTML+Token formatters.", + "drupal/insert": "Allows custom formatters targeting image/file fields to appear as Insert styles.", "drupal/token": "Provides token replacement support for the HTML Token formatter type." } } diff --git a/modules/insert.inc b/modules/insert.inc new file mode 100644 index 0000000..0c00886 --- /dev/null +++ b/modules/insert.inc @@ -0,0 +1,142 @@ +moduleExists('insert')) { + return []; + } + + if (!in_array($insert_type, ['image', 'file'], TRUE)) { + return []; + } + + $compatible_field_types = $insert_type === 'image' ? ['image'] : ['file', 'entity_reference']; + + $formatters = \Drupal::entityTypeManager() + ->getStorage('formatter') + ->loadByProperties(['status' => TRUE]); + + $styles = []; + foreach ($formatters as $formatter) { + $formatter_field_types = $formatter->get('field_types') ?? []; + if (empty(array_intersect($compatible_field_types, $formatter_field_types))) { + continue; + } + + $style_name = 'custom_formatters__' . $formatter->id(); + $styles[$style_name] = [ + 'label' => $formatter->label(), + 'weight' => 0, + ]; + } + + return $styles; +} + +/** + * Implements hook_insert_render(). + */ +function custom_formatters_insert_render(string $style_name, array $vars, array $insert_element): string { + if (!\Drupal::moduleHandler()->moduleExists('insert')) { + return ''; + } + + if (!str_starts_with($style_name, 'custom_formatters__')) { + return ''; + } + + $formatter_id = substr($style_name, strlen('custom_formatters__')); + + $formatter = \Drupal::entityTypeManager() + ->getStorage('formatter') + ->load($formatter_id); + + if (!$formatter instanceof CustomFormattersFormatterInterface || !$formatter->status()) { + return ''; + } + + $file = $vars['file'] ?? NULL; + if (!$file instanceof FileInterface) { + return ''; + } + + // Determine the field type based on what the formatter supports. + $formatter_field_types = $formatter->get('field_types') ?? []; + $field_type = 'entity_reference'; + foreach (['image', 'file', 'entity_reference'] as $type) { + if (in_array($type, $formatter_field_types, TRUE)) { + $field_type = $type; + break; + } + } + + // Create a temporary field definition matching the formatter's field type. + $field_definition = BaseFieldDefinition::create($field_type) + ->setName('_insert_file') + ->setTargetEntityTypeId('file'); + + // Suppress alt/title for image fields in this context. + if ($field_type === 'image') { + $field_definition->setSetting('alt_field', FALSE); + $field_definition->setSetting('title_field', FALSE); + } + + // Create a field item list with the file entity reference. + $items = new InsertFieldItemList( + $field_definition, + '_insert_file', + $file, + \Drupal::languageManager()->getCurrentLanguage()->getId(), + ); + + // Build the field item value based on the resolved field type. + $value = ['target_id' => (int) $file->id()]; + if ($field_type === 'image') { + $value['alt'] = ''; + $value['title'] = $file->getFilename(); + } + elseif ($field_type === 'file') { + $value['display'] = 1; + $value['description'] = ''; + } + $items->appendItem($value); + + // Instantiate the field formatter plugin. + $plugin_manager = \Drupal::service('plugin.manager.field.formatter'); + $formatter_instance = $plugin_manager->createInstance( + 'custom_formatters:' . $formatter_id, + [ + 'field_definition' => $field_definition, + 'settings' => [], + 'label' => 'hidden', + 'view_mode' => '_custom', + 'third_party_settings' => [], + ], + ); + + \assert($formatter_instance instanceof FormatterInterface); + $elements = $formatter_instance->viewElements($items, $items->getLangcode()); + if (empty($elements)) { + return ''; + } + + return (string) \Drupal::service('renderer')->renderInIsolation($elements); +} diff --git a/src/InsertFieldItemList.php b/src/InsertFieldItemList.php new file mode 100644 index 0000000..5274c85 --- /dev/null +++ b/src/InsertFieldItemList.php @@ -0,0 +1,56 @@ +entity = $file; + $this->langcode = $langcode; + } + + /** + * {@inheritdoc} + */ + public function getEntity() { + return $this->entity; + } + +} diff --git a/tests/src/Functional/CustomFormattersTestBase.php b/tests/src/Functional/CustomFormattersTestBase.php index c1a7dc7..aec10c8 100755 --- a/tests/src/Functional/CustomFormattersTestBase.php +++ b/tests/src/Functional/CustomFormattersTestBase.php @@ -72,6 +72,7 @@ protected function setUp(): void { 'administer content types', 'administer custom formatters', 'administer node display', + 'administer node form display', ]); // Ensure relevant configuration present if profile isn't 'standard'. diff --git a/tests/src/Functional/InsertIntegrationTest.php b/tests/src/Functional/InsertIntegrationTest.php new file mode 100644 index 0000000..c474e09 --- /dev/null +++ b/tests/src/Functional/InsertIntegrationTest.php @@ -0,0 +1,342 @@ + + */ + protected static $modules = [ + 'block', + 'custom_formatters_test', + 'insert', + 'field_ui', + 'file', + 'image', + 'node', + 'text', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Load the insert integration file so its functions are available + // for direct invocation in tests. + require_once \Drupal::root() . '/' . \Drupal::service('extension.list.module') + ->getPath('custom_formatters') . '/modules/insert.inc'; + } + + /** + * Test the style registration and field-type filtering logic. + * + * Verifies that custom_formatters_insert_styles() correctly: + * - Exposes image formatters for the 'image' insert type. + * - Exposes file and entity_reference formatters for the 'file' insert type. + * - Excludes non-matching field types. + * - Returns empty for unrecognized insert types. + */ + public function testInsertStylesFiltering(): void { + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('insert'), 'Insert stub module is active.'); + + $image_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['image'], + 'data' => "return 'img';", + ]); + + $file_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['file'], + 'data' => "return 'file';", + ]); + + $er_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['entity_reference'], + 'data' => "return 'er';", + ]); + + $text_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['text'], + 'data' => "return 'txt';", + ]); + + // 'image' insert type: only image formatters. + $image_styles = custom_formatters_insert_styles('image'); + $this->assertArrayHasKey('custom_formatters__' . $image_fmt->id(), $image_styles, 'Image formatter appears for image insert type.'); + $this->assertArrayNotHasKey('custom_formatters__' . $file_fmt->id(), $image_styles, 'File formatter excluded from image insert type.'); + $this->assertArrayNotHasKey('custom_formatters__' . $er_fmt->id(), $image_styles, 'Entity reference formatter excluded from image insert type.'); + $this->assertArrayNotHasKey('custom_formatters__' . $text_fmt->id(), $image_styles, 'Text formatter excluded from image insert type.'); + + // 'file' insert type: file and entity_reference formatters. + $file_styles = custom_formatters_insert_styles('file'); + $this->assertArrayNotHasKey('custom_formatters__' . $image_fmt->id(), $file_styles, 'Image formatter excluded from file insert type.'); + $this->assertArrayHasKey('custom_formatters__' . $file_fmt->id(), $file_styles, 'File formatter appears for file insert type.'); + $this->assertArrayHasKey('custom_formatters__' . $er_fmt->id(), $file_styles, 'Entity reference formatter appears for file insert type.'); + $this->assertArrayNotHasKey('custom_formatters__' . $text_fmt->id(), $file_styles, 'Text formatter excluded from file insert type.'); + + // Unrecognized insert type returns empty. + $this->assertEmpty(custom_formatters_insert_styles('video'), 'Unrecognized insert type returns empty.'); + } + + /** + * Test custom_formatters_insert_render() with an image formatter. + * + * Exercises the full code path including InsertFieldItemList. + */ + public function testInsertRender(): void { + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('insert'), 'Insert stub module is active.'); + + $formatter = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['image'], + 'data' => "return 'RENDERED:' . \$items->first()->entity->getFilename();", + ]); + + $images = $this->getTestFiles('image'); + $image = reset($images); + $file = File::create([ + 'uri' => $image->uri ?? '', + 'uid' => 1, + 'status' => 1, + ]); + $file->save(); + + $style_name = 'custom_formatters__' . $formatter->id(); + + // Verify the formatter appears in Insert styles. + $styles = custom_formatters_insert_styles('image'); + $this->assertArrayHasKey($style_name, $styles, 'Formatter appears as an Insert style.'); + + // Verify the full insert_render hook path, exercising InsertFieldItemList. + $rendered = custom_formatters_insert_render($style_name, ['file' => $file], []); + $this->assertNotEmpty($rendered, 'insert_render returned a non-empty string.'); + $this->assertStringContainsString('RENDERED:', $rendered, 'Formatter output prefix is present.'); + $this->assertStringContainsString($file->getFilename(), $rendered, 'Formatter output contains the filename.'); + + // Non-matching style name returns empty. + $this->assertSame('', custom_formatters_insert_render('other_module__style', ['file' => $file], []), 'Non-matching style returns empty string.'); + + // Missing file returns empty. + $this->assertSame('', custom_formatters_insert_render($style_name, [], []), 'Missing file returns empty string.'); + } + + /** + * Test rendering through a custom formatter using a node-based approach. + * + * Creates a node with an image field, attaches a file, and renders the + * field through a custom formatter — the same rendering path used by + * custom_formatters_insert_render(). + */ + public function testFormatterRenderingWithFileFieldItemList(): void { + // Create an image field on the article content type. + $field_storage = \Drupal::entityTypeManager() + ->getStorage('field_storage_config') + ->create([ + 'field_name' => 'field_render_test', + 'type' => 'image', + 'entity_type' => 'node', + ]); + $field_storage->save(); + + $field_config = \Drupal::entityTypeManager() + ->getStorage('field_config') + ->create([ + 'field_name' => 'field_render_test', + 'entity_type' => 'node', + 'bundle' => 'article', + 'label' => 'Render Test', + ]); + $field_config->save(); + + // Create the test file. + $images = $this->getTestFiles('image'); + $image = reset($images); + $file = File::create([ + 'uri' => $image->uri ?? '', + 'uid' => 1, + 'status' => 1, + ]); + $file->save(); + + // Create a node with the image field. + $node = Node::create([ + 'type' => 'article', + 'title' => 'Insert Render Test', + 'field_render_test' => [ + 'target_id' => $file->id(), + 'alt' => '', + 'title' => $file->getFilename(), + ], + ]); + $node->save(); + + // Create the custom formatter for image fields. + // Uses $items->first()->entity to access the referenced file entity. + $formatter = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['image'], + 'data' => "return 'RENDERED:' . \$items->first()->entity->getFilename();", + ]); + + // Get the FieldItemList from the node (properly constructed by the entity + // system with the correct TypedData parent chain). + $items = $node->get('field_render_test'); + + // Create the formatter plugin instance. + $plugin_manager = \Drupal::service('plugin.manager.field.formatter'); + $formatter_instance = $plugin_manager->createInstance( + 'custom_formatters:' . $formatter->id(), + [ + 'field_definition' => $field_config, + 'settings' => [], + 'label' => 'hidden', + 'view_mode' => '_custom', + 'third_party_settings' => [], + ], + ); + + \assert($formatter_instance instanceof FormatterInterface); + $elements = $formatter_instance->viewElements($items, $items->getLangcode()); + + $this->assertNotEmpty($elements, 'Rendering returned elements.'); + $rendered = (string) \Drupal::service('renderer')->renderInIsolation($elements); + $this->assertStringContainsString('RENDERED:', $rendered, 'Formatter output contains expected prefix.'); + $this->assertStringContainsString($file->getFilename(), $rendered, 'Formatter output contains the filename.'); + } + + /** + * Test rendering via entity_reference through a custom formatter. + */ + public function testFormatterRenderingWithEntityReferenceFieldItemList(): void { + // Create an entity_reference field on the article content type. + $field_storage = \Drupal::entityTypeManager() + ->getStorage('field_storage_config') + ->create([ + 'field_name' => 'field_er_test', + 'type' => 'entity_reference', + 'entity_type' => 'node', + 'settings' => ['target_type' => 'file'], + ]); + $field_storage->save(); + + $field_config = \Drupal::entityTypeManager() + ->getStorage('field_config') + ->create([ + 'field_name' => 'field_er_test', + 'entity_type' => 'node', + 'bundle' => 'article', + 'label' => 'ER Test', + 'settings' => ['handler' => 'default:file'], + ]); + $field_config->save(); + + // Create the test file. + $images = $this->getTestFiles('image'); + $image = reset($images); + $file = File::create([ + 'uri' => $image->uri ?? '', + 'uid' => 1, + 'status' => 1, + ]); + $file->save(); + + // Create a node with the entity_reference field. + $node = Node::create([ + 'type' => 'article', + 'title' => 'Insert ER Test', + 'field_er_test' => ['target_id' => $file->id()], + ]); + $node->save(); + + // Create the custom formatter for entity_reference fields. + $formatter = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['entity_reference'], + 'data' => "return 'ER-RENDERED:' . \$items->first()->entity->getFilename();", + ]); + + // Get the FieldItemList from the node. + $items = $node->get('field_er_test'); + + // Create the formatter plugin instance. + $plugin_manager = \Drupal::service('plugin.manager.field.formatter'); + $formatter_instance = $plugin_manager->createInstance( + 'custom_formatters:' . $formatter->id(), + [ + 'field_definition' => $field_config, + 'settings' => [], + 'label' => 'hidden', + 'view_mode' => '_custom', + 'third_party_settings' => [], + ], + ); + + \assert($formatter_instance instanceof FormatterInterface); + $elements = $formatter_instance->viewElements($items, $items->getLangcode()); + + $this->assertNotEmpty($elements, 'Entity reference rendering returned elements.'); + $rendered = (string) \Drupal::service('renderer')->renderInIsolation($elements); + $this->assertStringContainsString('ER-RENDERED:', $rendered, 'Entity reference formatter output contains expected prefix.'); + $this->assertStringContainsString($file->getFilename(), $rendered, 'Formatter output contains the filename.'); + } + + /** + * Test that the manage form display page loads without errors. + */ + public function testManageDisplayPage(): void { + // Create an image field on the article content type. + $field_storage = \Drupal::entityTypeManager() + ->getStorage('field_storage_config') + ->create([ + 'field_name' => 'field_display_test', + 'type' => 'image', + 'entity_type' => 'node', + ]); + $field_storage->save(); + + \Drupal::entityTypeManager() + ->getStorage('field_config') + ->create([ + 'field_name' => 'field_display_test', + 'entity_type' => 'node', + 'bundle' => 'article', + 'label' => 'Display Test', + ]) + ->save(); + + $this->drupalGet('admin/structure/types/manage/article/form-display'); + $this->assertSession()->statusCodeEquals(200); + } + +} From 9bbeb8248f5d6a4bd232a46747f298d2db44616b Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Tue, 9 Jun 2026 14:56:52 +1000 Subject: [PATCH 2/2] fix: address CodeRabbit review findings on Insert integration - composer.json: mention entity_reference in drupal/insert suggest description - insert.inc: initialise $field_type to NULL and bail early if the formatter does not target an Insert-compatible field type (prevents unintended fallback to entity_reference for non-file formatters) - insert.inc: wrap formatter instantiation and render pipeline in try/catch Throwable so plugin failures and runtime errors return '' instead of a 500 (with logger::error for diagnostics) --- composer.json | 2 +- modules/insert.inc | 54 ++++++++++++------- .../src/Functional/InsertIntegrationTest.php | 22 +++++++- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index f07a531..72a25d4 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "drupal/codemirror_editor": "Provides syntax-highlighted code editing via CodeMirror for the PHP, HTML+Token, and Twig formatter engines.", "drupal/devel": "Provides debug output options for the preview feature and Devel Generate integration for generating sample preview content.", "drupal/field_tokens": "Provides field-level token support, useful for testing HTML+Token formatters.", - "drupal/insert": "Allows custom formatters targeting image/file fields to appear as Insert styles.", + "drupal/insert": "Allows custom formatters targeting image, file, and entity_reference fields to appear as Insert styles.", "drupal/token": "Provides token replacement support for the HTML Token formatter type." } } diff --git a/modules/insert.inc b/modules/insert.inc index 0c00886..65c11dc 100644 --- a/modules/insert.inc +++ b/modules/insert.inc @@ -80,7 +80,7 @@ function custom_formatters_insert_render(string $style_name, array $vars, array // Determine the field type based on what the formatter supports. $formatter_field_types = $formatter->get('field_types') ?? []; - $field_type = 'entity_reference'; + $field_type = NULL; foreach (['image', 'file', 'entity_reference'] as $type) { if (in_array($type, $formatter_field_types, TRUE)) { $field_type = $type; @@ -88,6 +88,10 @@ function custom_formatters_insert_render(string $style_name, array $vars, array } } + if ($field_type === NULL) { + return ''; + } + // Create a temporary field definition matching the formatter's field type. $field_definition = BaseFieldDefinition::create($field_type) ->setName('_insert_file') @@ -119,24 +123,38 @@ function custom_formatters_insert_render(string $style_name, array $vars, array } $items->appendItem($value); - // Instantiate the field formatter plugin. - $plugin_manager = \Drupal::service('plugin.manager.field.formatter'); - $formatter_instance = $plugin_manager->createInstance( - 'custom_formatters:' . $formatter_id, - [ - 'field_definition' => $field_definition, - 'settings' => [], - 'label' => 'hidden', - 'view_mode' => '_custom', - 'third_party_settings' => [], - ], - ); + // Instantiate the field formatter plugin and render. + $ob_level = ob_get_level(); + try { + $plugin_manager = \Drupal::service('plugin.manager.field.formatter'); + $formatter_instance = $plugin_manager->createInstance( + 'custom_formatters:' . $formatter_id, + [ + 'field_definition' => $field_definition, + 'settings' => [], + 'label' => 'hidden', + 'view_mode' => '_custom', + 'third_party_settings' => [], + ], + ); + + \assert($formatter_instance instanceof FormatterInterface); + $elements = $formatter_instance->viewElements($items, $items->getLangcode()); + if (empty($elements)) { + return ''; + } - \assert($formatter_instance instanceof FormatterInterface); - $elements = $formatter_instance->viewElements($items, $items->getLangcode()); - if (empty($elements)) { + return (string) \Drupal::service('renderer')->renderInIsolation($elements); + } + catch (\Throwable $e) { + // Close any output buffers the formatter engine opened before throwing. + while (ob_get_level() > $ob_level) { + ob_end_clean(); + } + \Drupal::logger('custom_formatters')->error( + 'Insert render failed for formatter %id: @message', + ['%id' => $formatter_id, '@message' => $e->getMessage()], + ); return ''; } - - return (string) \Drupal::service('renderer')->renderInIsolation($elements); } diff --git a/tests/src/Functional/InsertIntegrationTest.php b/tests/src/Functional/InsertIntegrationTest.php index c474e09..a47dcb5 100644 --- a/tests/src/Functional/InsertIntegrationTest.php +++ b/tests/src/Functional/InsertIntegrationTest.php @@ -117,10 +117,11 @@ public function testInsertStylesFiltering(): void { public function testInsertRender(): void { $this->assertTrue(\Drupal::moduleHandler()->moduleExists('insert'), 'Insert stub module is active.'); + // Use $items->getEntity() to exercise InsertFieldItemList::getEntity(). $formatter = $this->createCustomFormatter([ 'type' => 'php', 'field_types' => ['image'], - 'data' => "return 'RENDERED:' . \$items->first()->entity->getFilename();", + 'data' => "return 'RENDERED:' . \$items->getEntity()->getFilename();", ]); $images = $this->getTestFiles('image'); @@ -149,6 +150,25 @@ public function testInsertRender(): void { // Missing file returns empty. $this->assertSame('', custom_formatters_insert_render($style_name, [], []), 'Missing file returns empty string.'); + + // Formatter targeting a non-Insert-compatible type returns empty + // (covers the $field_type === NULL early return). + $text_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['text'], + 'data' => "return 'text';", + ]); + $text_style = 'custom_formatters__' . $text_fmt->id(); + $this->assertSame('', custom_formatters_insert_render($text_style, ['file' => $file], []), 'Non-file formatter returns empty string.'); + + // Formatter whose PHP throws covers the catch(\Throwable) error path. + $broken_fmt = $this->createCustomFormatter([ + 'type' => 'php', + 'field_types' => ['image'], + 'data' => "throw new \\RuntimeException('insert render test error');", + ]); + $broken_style = 'custom_formatters__' . $broken_fmt->id(); + $this->assertSame('', custom_formatters_insert_render($broken_style, ['file' => $file], []), 'Formatter exception returns empty string.'); } /**