Field Label Visibility allows site builders to customize the label of individual fields per widget directly from "Manage Form Display" for selected content types. Per-field options include:

  • Hide the label accessibly
  • Render the label with a custom text (singular/plural mode), tag, CSS class, alignment and bold styling
  • A CKEditor-style toolbar UI for alignment and bold settings

The module is XSS-safe by design: tag allowlist, regex-filtered class names, alignment allowlist, and escaped text. Server-side validation mirrors the runtime sanitization.

Differences with similar projects

Unlike field_label_replacement and similar modules, this module:

  • Operates at the form widget level (not the entity display level)
  • Provides a CKEditor-style inline toolbar in Manage Form Display
  • Supports semantic vs decorative tag distinction with automatic `aria-hidden` for decorative cases while preserving the original accessible name

Project link

https://www.drupal.org/project/field_label_visibility

Comments

pedroromán created an issue. See original summary.

vishal.kadam’s picture

Assigned: pedroromán » Unassigned
Issue summary: View changes
avpaderno’s picture

Thank you for applying!

Before giving links helpful to understand how the review process works, what to expect from a review, and what to do to avoid a review takes more time than needed, I would like to thank all the reviewers for the work they do.
These applications are volunters-driven, which also means it is not possible to predict when an application will be marked fixed and the applicant will get the permission to opt projects into security advisory policy. While we aim to make an application as quick as possible, it is also important for us that more people review the project used for an application. In this way, we make sure applications do not miss some important points that should be instead reported.
Applications are not meant to be complete debugging sessions that eliminate every existing bug, though. I apologize if sometimes applications seem to go into too-detailed reviews.

Please read Review process for security advisory coverage: What to expect for more details and Security advisory coverage application checklist to understand what reviewers look for. Tips for ensuring a smooth review gives some hints for a smoother review.

The important notes are the following.

  • If you have not done it yet, you should enable GitLab CI for the project and fix the PHP_CodeSniffer errors/warnings it reports.
  • For the time this application is open, only your commits are allowed.
  • The purpose of this application is giving you a new drupal.org role that allows you to opt projects into security advisory coverage, either projects you already created, or projects you will create. The project status will not be changed by this application; once this application is closed, you will be able to change the project status from Not covered to Opt into security advisory coverage. This is possible only 14 days after the project is created.

    Keep in mind that once the project is opted into security advisory coverage, only Security Team members may change coverage.
  • Only the person who created the application will get the permission to opt projects into security advisory coverage. No other person will get the same permission from the same application; that applies also to co-maintainers/maintainers of the project used for the application.
  • We only accept an application per user. If you change your mind about the project to use for this application, or it is necessary to use a different project for the application, please update the issue summary with the link to the correct project and the issue title with the project name and the branch to review.

To the reviewers

Please read How to review security advisory coverage applications, Application workflow, What to cover in an application review, and Tools to use for reviews.

The important notes are the following.

  • It is preferable to wait for a project moderator before posting the first comment on newly created applications. Project moderators will do some preliminary checks that are necessary before any change on the project files is suggested.
  • Reviewers should show the output of a CLI tool only once per application.
  • It may be best to have the applicant fix things before further review.

For new reviewers, I would also suggest to first read In which way the issue queue for coverage applications is different from other project queues.

vishal.kadam’s picture

Status: Needs review » Needs work

1. Add a README.md file

The project is missing a README.md file, it should follow the template suggested in README.md template.

2. FILE: field_label_visibility.libraries.yml

version: VERSION

VERSION is only used by Drupal core modules. Contributed modules should use a literal string that does not change with the Drupal core version a site is using.

3. FILE: field_label_visibility.module

Since the module is declared compatible with Drupal 10.3, removing the function implementing the hook is not possible. The function still needs to be defined, but it calls the method defined by the service class, as described in Support for object oriented hook implementations using autowired services (Backwards-compatible Hook implementation for Drupal versions from 10.1 to 11.0).

4. FILE: src/Form/SettingsForm.php

With Drupal 10 and Drupal 11, there is no longer need to use #default_value for each form element, when the parent class is ConfigFormBase: It is sufficient to use #config_target, as in the following code.

    $form['image_toolkit'] = [
      '#type' => 'radios',
      '#title' => $this->t('Select an image processing toolkit'),
      '#config_target' => 'system.image:toolkit',
      '#options' => [],
    ];

Using that code, it is no longer needed to save the configuration values in the form submission handler: The parent class will take care of that.

pedroromán’s picture

Issue summary: View changes

working...

pedroromán’s picture

Status: Needs work » Needs review

Thanks for the review. I've addressed the four points in tag 1.0.2 (commit 73ca5fa on 1.0.x):

Added README.md following the suggested template.

Replaced version: VERSION with the literal 1.0.x in field_label_visibility.libraries.yml.

field_label_visibility.module now exposes procedural hook entry points that delegate to the FieldLabelVisibilityHooks service, keeping the #[Hook] attributes for Drupal 11 while staying compatible with Drupal 10.3.

SettingsForm now uses #config_target with \Drupal\Core\Form\ConfigTarget, so the manual #default_value, validateForm and submitForm are gone. The toConfig closure preserves the intersect+sort defense-in-depth.

vishal.kadam’s picture

Issue summary: View changes
vishal.kadam’s picture

Issue summary: View changes
vishal.kadam’s picture

It is better not to create new releases during these applications, since a review could ask for a change that is not backward compatible with the existing releases. Just using a development version avoids those BC issues.

vishal.kadam’s picture

Status: Needs review » Needs work

FILE: field_label_visibility.module

Procedural hooks should be marked with the #[LegacyHook] attribute.

pedroromán’s picture

Priority: Normal » Major
Status: Needs work » Needs review

Thanks again for the detailed feedback.

Pushed to the 1.0.x branch (no new tag, per your guidance):

  • Commit 460fb90: tagged the three procedural hook bridges in field_label_visibility.module with #[LegacyHook] and added the Drupal\Core\Hook\Attribute\LegacyHook import. The bridges keep delegating to the OOP FieldLabelVisibilityHooks service, but they are now correctly identified as legacy bridges rather than independent handlers. The use statement is a static alias only, so on Drupal 10.3 (where the attribute class does not exist yet) the attribute is simply not reflected and the bridges keep working — core_version_requirement: ^10.3 || ^11 is preserved.
  • Commit a8977b8: two preventive cleanups while reviewing the module:
    1. Removed the tags: { name: drupal.module_listener } entry from field_label_visibility.services.yml. That tag is not defined by Drupal core and was not doing anything; hook discovery relies on autoconfigure: true and the #[Hook] attribute, which matches how core hook services are registered.
    2. Translated every user-facing string to English so localize.drupal.org can pick them up — module description and package, permission title/description, route title, menu link title/description, all schema labels, ConfigForm labels and descriptions, every $this->t() call in the OOP hook class (including the validator error messages). Existing kernel test summary assertions were updated accordingly.

Local verification on Drupal 11.x (DDEV, PHP 8.4):

  • phpcs --standard=Drupal,DrupalPractice: 0 violations.
  • phpstan analyse --level=1: 0 errors.
  • PHPUnit kernel tests: 14/14 passing, 191 assertions.

Setting back to "Needs review".

vishal.kadam’s picture

Priority: Major » Normal

Priority should be change as per Issue priorities.

vishal.kadam’s picture

Rest seems fine to me.

Please wait for other reviewers and Project Moderator to take a look and if everything goes fine, you will get the role.

avpaderno’s picture

Status: Needs review » Needs work
  • The following points are just a start and don't necessarily encompass all of the changes that may be necessary
  • A specific point may just be an example and may apply in other places
  • A review is about code that does not follow the coding standards, contains possible security issue, or does not correctly use the Drupal API
  • The single review points are not ordered, not even by importance

src/Hook/FieldLabelVisibilityHooks.php

  /**
   * Server-side validator for the "custom_label" textfield.
   *
   * Static signature is required by Drupal Form API for
   * #element_validate callbacks. \Drupal::translation() is the accepted
   * fallback in this static context — DI is unavailable to procedural
   * callbacks.
   */

Documentation comments for methods and functions need to also document parameters and return value, if there is any.

        \Drupal::translation()->translate('The custom label cannot contain the @lt or @gt characters.', [
          '@lt' => '<',
          '@gt' => '>',
        ]),

For a static method, it is sufficient to use t() or create a \Drupal\Core\StringTranslation\TranslatableMarkup instance, which what the documentation for t() suggests.

    if (
      !$widget instanceof WidgetInterface
      || !$field_definition instanceof FieldDefinitionInterface
    ) {
      return;
    }

Control statements are allowed to exceed the 80-character limit. If the checked conditions would be too much, Drupal coding standards suggests using local variables for the conditions, as in the following code.

  // Key is only valid if it matches the current user's ID, as otherwise other
  // users could access any user's things.
  $is_valid_user = isset($key) && !empty($user->uid) && $key == $user->uid;

   // IP must match the cache to prevent session spoofing.
  $is_valid_cache = isset($user->cache) ? $user->cache == ip_address() : FALSE;

  // Alternatively, if the request query parameter is in the future, then it
  // is always valid, because the galaxy will implode and collapse anyway.
  $is_valid_query = $is_valid_cache || (isset($value) && $value >= time());

  if ($is_valid_user || $is_valid_query) {
    // ...
  }

(Keep in mind that code in issues is not shown as entered; there could be new lines where the code did not have any.)

    if ($settings['bold']) {
      $formats[] = (string) $this->t('Bold');
    }
    if ($settings['italic']) {
      $formats[] = (string) $this->t('Italic');
    }
    if ($settings['underline']) {
      $formats[] = (string) $this->t('Underline');
    }
    if ($formats !== []) {
      $summary[] = $this->t('Format: @formats', ['@formats' => implode(' / ', $formats)]);
    }

There is no need to cast the value returned by $this->t() to a string; in contexts where a string is required, PHP does that cast automatically.

field_label_visibility.services.yml

Services can be autowired starting with Drupal 9.3.x. This means that it is no longer necessary to give a list of service arguments in the .services.yml file; they will be retrieved from the constructor definition.

avpaderno’s picture

Issue summary: View changes

What reported here in Differences with similar projects needs to be added to the project page.

pedroromán’s picture

Status: Needs work » Needs review

All findings from comment #14 have been addressed on the `1.0.x` branch. No new tag has been created.

**Commit `ed5cb89`** — review feedback:

- `src/Hook/FieldLabelVisibilityHooks.php`
- Every method (constructor, hook implementations, static validators and private helpers) now carries a complete docblock with `@param` and `@return` entries.
- The three static `#element_validate` callbacks (`validateCustomLabel`, `validateLabelClass`, `validateLabelId`) now use the `t()` function instead of `\Drupal::translation()->translate()`, since they run in a static context where `StringTranslationTrait` is not available.
- Unnecessary `(string)` casts around `$this->t()` calls in `widgetSettingsSummaryAlter()` have been removed. The only remaining cast is on the toolbar `aria-label`, justified inline because `Html::escape()` declares a `string` parameter and `strict_types` forbids implicit `Stringable` coercion.
- Long inline conditions (the "Hide label" `#states` selector, `$has_customization`, the wrapper-tag and id/style branches in `applyCustomLabel`, and the `widget`/`field_definition` instanceof check in `widgetSettingsSummaryAlter`) have been extracted into named local variables to keep each line below 80 characters.
- `field_label_visibility.module` — the three procedural `#[LegacyHook]` bridges gained matching `@param` and `@return` docblocks.
- `field_label_visibility.services.yml` — switched to `autowire: true`; the explicit argument list is gone, since both `ConfigFactoryInterface` and `TranslationInterface` are aliased in `core.services.yml`. Container compilation and runtime resolution have been verified.

**Commit `db31c0b`** — additional refactor (scope outside review feedback, but worth flagging):

The runtime no longer injects a parallel decorative element next to the field label. Three operating modes:

- **Default** (no wrapper tag): id, class and inline style are applied to the native `` element through Drupal's form element preprocess pipeline (two new `#[Hook]` services, `preprocess_form_element` and `preprocess_form_element_label`, plus their `#[LegacyHook]` procedural bridges). The `for=` association with the input is preserved.
- **Wrapper tag** (`span` / `small` / `mark`): the `#title` is replaced with a `Markup` instance that wraps the text in the chosen phrasing-content tag with the configured id/class/style; the wrapping `` stays in place. Tags not allowed as `` children (`h1`–`h6`, `p`, `legend`) have been removed from the option list because they would produce HTML invalid per the WHATWG spec.
- **Hide**: `#title_display = 'none'` instead of `'invisible'`. Core stops rendering the `` entirely; the markup is absent from the DOM rather than visually hidden.

End-to-end verified by rendering a `/node/add/post` form on a real Drupal 11 site:

- Default mode → `Custom`
- Wrapper mode → `Custom`
- Hide mode → no `` element in the DOM

The README has been rewritten to describe the new modes accurately.

**Quality gates**:

- `phpcs --standard=Drupal,DrupalPractice` — 0 violations
- `phpstan analyse --level=1` — 0 errors
- PHPUnit kernel tests — 17/17 passing, 227 assertions

**Project page update**: the "Differences with similar projects" content has been added to the project page itself, as requested.

avpaderno’s picture

Status: Needs review » Needs work
Issue tags: +PAreview: security

field_label_visibility.module

/**
 * @file
 * Module file for field_label_visibility.
 *
 * The hook implementations live in
 * \Drupal\field_label_visibility\Hook\FieldLabelVisibilityHooks via the
 * #[Hook] attribute discovery introduced in Drupal 10.3 and required in
 * Drupal 11.
 *
 * Drupal 10.3 still supports legacy procedural hook discovery alongside
 * the attribute-based one, but legacy modules and contrib code paths
 * occasionally invoke procedural hooks directly. To stay compatible with
 * the full ^10.3 || ^11 range declared in the .info.yml file, the
 * procedural hook entry points below delegate to the same service
 * methods used by the attribute discovery and are tagged with
 * #[LegacyHook] so that Drupal's hook system knows they are bridges to
 * the OOP implementation rather than independent hook handlers.
 *
 * Once the minimum supported core version is raised to ^11, these
 * procedural bridges may be removed.
 */

The usual short description (Hook implementations for the Field Label Visibility module.) gives more information about the file content.

The rest of that documentation comment is not information a developer would need. It seems the type of comments an AI-assisted software would suggest. A maintainer could just need a @todo note that reminds that legacy hooks can be removed when Drupal 11 is the minimum required Drupal core version.

The use of AI-assisted software to write code needs to be disclosed in the project page.

/**
 * Implements hook_field_widget_third_party_settings_form().
 *
 * @param \Drupal\Core\Field\WidgetInterface $plugin
 *   The widget plugin being configured.
 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
 *   The definition of the field whose widget is being configured.
 * @param string $form_mode
 *   The form mode being configured (for example, 'default').
 * @param array $form
 *   The (parent) form array.
 * @param \Drupal\Core\Form\FormStateInterface $form_state
 *   The form state of the parent form.
 *
 * @return array
 *   The render array of third-party settings injected under the widget.
 */

Documentation comments for hooks just need the short description. A long description can be added to add details necessary when developing the module, for example a reminder of why a hook has been used instead of another one, or why the hook implementation is done that way. The description of the parameters and the return value is not necessary; it is something AI-assisted software tends to add.

field_label_visibility.info.yml

core_version_requirement: ^10.3 || ^11
php: '>=8.3'

May you explain why the module would require PHP 8.3 or any higher version?

src/Form/SettingsForm.php

        toConfig: static function (array $submitted) use ($valid_bundles): array {
          $selected = array_values(array_intersect(
            $valid_bundles,
            array_keys(array_filter($submitted)),
          ));
          sort($selected);
          return $selected;
        },

May you describe what that code does and why is it necessary?

src/Hook/FieldLabelVisibilityHooks.php

    if (preg_match('/[<>]/', $value) === 1) {
      $form_state->setError(
        $element,
        t('The custom label cannot contain the @lt or @gt characters.', [
          '@lt' => '<',
          '@gt' => '>',
        ]),
      );
    }

Why does the code check preg_match() returns 1?

        '#pattern' => '[a-zA-Z0-9_\\- ]*',
        '#element_validate' => [[static::class, 'validateLabelClass']],

Only one of those lines is usually necessary. May you describe the reason for using a custom validation handler?

      $form_state->setError(
        $element,
        t('The custom label cannot contain the @lt or @gt characters.', [
          '@lt' => '<',
          '@gt' => '>',
        ]),
      );

It is correct that using $this->t() is not possible in static methods, but what are the alternatives?
(As a side note, the comment explaining that $this->t() cannot be used in static methods is not necessary.)

      self::SETTING_CLASS => [
        '#type' => 'textfield',
        '#title' => $this->t('CSS classes'),
        '#description' => $this->t('Additional CSS classes separated by spaces. Only letters, digits, hyphen, underscore and spaces are allowed.'),
        '#default_value' => (string) $plugin->getThirdPartySetting(
          self::MODULE,
          self::SETTING_CLASS,
          '',
        ),
        '#maxlength' => 255,
        '#pattern' => '[a-zA-Z0-9_\\- ]*',
        '#element_validate' => [[static::class, 'validateLabelClass']],
        '#states' => $hidden_when_hide,

That regular expression is not correct to validate a CSS class name. Drupal core has a class method that can be used to verify a CSS class name is valid.

      self::SETTING_LABEL => [
        '#type' => 'textfield',
        '#title' => $this->t('Custom label'),
        '#description' => $this->t('When filled in, replaces the default field label. Leave empty to keep the original. With "Singular/Plural" enabled you can write <em>Singular|Plural</em> separated by a pipe.'),
        '#default_value' => (string) $plugin->getThirdPartySetting(
          self::MODULE,
          self::SETTING_LABEL,
          '',
        ),
        '#maxlength' => 255,
        '#element_validate' => [[static::class, 'validateCustomLabel']],
        '#states' => $hidden_when_hide,
      ],

If some characters are not allowed in a custom label, the form element description should say they are not allowed.
Why does the validation handler allow other characters Html::escape() would convert to HTML entities?

    $tag_is_allowed = in_array($settings['tag'], self::ALLOWED_TAGS, TRUE);
    $safe_tag = $tag_is_allowed ? $settings['tag'] : '';
    $filtered_class = preg_replace('/[^a-zA-Z0-9_\- ]/', '', $settings['class']) ?? '';
    $safe_class = trim(preg_replace('/\s+/', ' ', $filtered_class) ?? '');

    $id_is_valid = preg_match(self::ID_REGEX, $settings['id']) === 1;
    $safe_id = $id_is_valid ? $settings['id'] : '';

    $style_parts = [];
    if (in_array($settings['align'], self::ALLOWED_ALIGNS, TRUE)) {
      $style_parts[] = 'text-align:' . $settings['align'];
    }
    if ($settings['bold']) {
      $style_parts[] = 'font-weight:bold';
    }
    if ($settings['italic']) {
      $style_parts[] = 'font-style:italic';
    }
    if ($settings['underline']) {
      $style_parts[] = 'text-decoration:underline';
    }

    $text = $this->resolveLabelText($settings, $field_definition, $items);

    if ($safe_tag !== '') {
      $id_attr = $safe_id !== '' ? ' id="' . $safe_id . '"' : '';
      $class_attr = $safe_class !== '' ? ' class="' . $safe_class . '"' : '';
      $style_attr = '';
      if ($style_parts !== []) {
        $style_attr = ' style="' . implode(';', $style_parts) . ';"';
      }
      $escaped_text = Html::escape($text);
      $html = '<' . $safe_tag . $id_attr . $class_attr . $style_attr . '>'
        . $escaped_text
        . '</' . $safe_tag . '>';
      $this->setWidgetTitle($field_widget_complete_form, Markup::create($html));
      return;
    }

styles should never be used to style HTML markup.
That code should use Xss::filter(), which also prevent cross-site-scripting vulnerabilities.

    if ($settings['singular_plural'] && str_contains($custom, '|')) {
      [$singular, $plural] = explode('|', $custom, 2);
      $singular = trim($singular);
      $plural = trim($plural);
      $count = $items->count();
      return $count === 1 ? $singular : $plural;
    }

That code only works for English; for other languages, that does not work. In Russian, for example the singular form is used for 1, 101, and other numbers for which the plural form would be used in English.

pedroromán’s picture

Thank you for the detailed review. Commit 5f86c36 on branch 1.0.x
addresses every point raised in #17. Summary below, mapped 1-to-1
to the findings in your comment.

**field_label_visibility.module**

- Documentation style — the file docblock has been trimmed to a
single short description ("Procedural hook bridges for
field_label_visibility.") and the per-hook bridges keep only
the `Implements hook_XXX()` short description; `@param` and
`@return` blocks have been removed.

**field_label_visibility.info.yml**

- PHP requirement — `php: '>=8.3'` has been removed. The module
uses nothing newer than PHP 8.1 (constructor promotion +
readonly), so `core_version_requirement: ^10.3 || ^11` is the
only constraint that is needed.

**src/Form/SettingsForm.php**

- The `toConfig` closure now carries an inline comment explaining
that the `checkboxes` element submits every option (selected and
unselected), why the result is intersected with the live
node-type list (so options removed since the form was rendered
cannot leak into config) and why `sort()` is applied (stable
output, no spurious diffs on config export).

**src/Hook/FieldLabelVisibilityHooks.php**

- `preg_match() === 1` is gone. The three server-side
`#element_validate` callbacks (`validateCustomLabel`,
`validateLabelClass`, `validateLabelId`) have been dropped
entirely. Invalid input is filtered at render time; valid
output is guaranteed by Drupal core helpers.
- CSS classes — each whitespace-separated token is now passed
through `\Drupal\Component\Utility\Html::cleanCssIdentifier()`
so the rendered value is always a valid CSS identifier. The
double `preg_replace` pass has been removed.
- Custom label — the `<` / `>` blacklist validator is gone. The
form element description now states that the text is rendered
as plain text and HTML special characters are escaped on output;
`Html::escape()` handles the rest in the render path.
- Inline styles removed. Bold, italic, underline and alignment
are now expressed as utility CSS classes shipped by the module:
`.flv-label--bold`, `.flv-label--italic`, `.flv-label--underline`,
`.flv-label--align-left`, `.flv-label--align-center` and
`.flv-label--align-right`. A new library
`field_label_visibility/label` (css/label.css) carries them and
is attached to the widget render array on demand. The module
no longer injects any `style=""` attribute.
- Wrapper-mode HTML is built with `\Drupal\Core\Template\Attribute`
for safe attribute serialization and passed through
`\Drupal\Component\Utility\Xss::filter()` with the
`['span', 'small', 'mark']` allowlist as defense in depth before
being wrapped in `Markup::create()`.
- Singular/Plural toggle removed. The previous
`explode('|', $custom)` approach only models the English
singular/plural binary; it does not represent the plural forms
of Russian, Arabic, Polish, Gaelic and others. The setting, its
schema entry, its summary line and the related Kernel tests
have been removed. Multilingual sites should use Drupal's
standard field-label translation flow.

**README.md**

- The "PHP 8.3 or newer" requirement line has been removed; the
Singular/Plural usage paragraph has been removed; the
sanitization paragraph now reflects `Html::cleanCssIdentifier()`
and the absence of inline styles.

**Quality**

- `phpcs --standard=Drupal,DrupalPractice --extensions=php,module,install,inc`
on the full module: 0 violations.
- PHP 8.3 syntax check on every PHP file: clean.
- No new release tag has been created during the review.

The "Differences with similar projects" content is already on the
project page (added in commit ed5cb89, comment #16).

pedroromán’s picture

Status: Needs work » Needs review
avpaderno’s picture

Status: Needs review » Needs work

For four of the points I listed in my previous comment, I asked questions. Those points were not suggesting changes but expecting answers.

        toConfig: static function (array $submitted) use ($valid_bundles): array {
          $selected = array_values(array_intersect(
            $valid_bundles,
            array_keys(array_filter($submitted)),
          ));
          sort($selected);
          return $selected;
        },

May you describe what that code does and why is it necessary?

    if (preg_match('/[<>]/', $value) === 1) {
      $form_state->setError(
        $element,
        t('The custom label cannot contain the @lt or @gt characters.', [
          '@lt' => '<',
          '@gt' => '>',
        ]),
      );
    }

Why does the code check preg_match() returns 1?

        '#pattern' => '[a-zA-Z0-9_\\- ]*',
        '#element_validate' => [[static::class, 'validateLabelClass']],

Only one of those lines is usually necessary. May you describe the reason for using a custom validation handler?

      $form_state->setError(
        $element,
        t('The custom label cannot contain the @lt or @gt characters.', [
          '@lt' => '<',
          '@gt' => '>',
        ]),
      );

It is correct that using $this->t() is not possible in static methods, but what are the alternatives?

avpaderno’s picture

As a further note, these applications never require removing implemented features, as long as those features are not already provided by Drupal core and the code just need to be adjusted to use classes or functions made available by Drupal core.
Changing the code that was incorrectly building markup is correct, but removing code to avoid to correctly implementing a feature is not correct.

pedroromán’s picture

Status: Needs work » Needs review

Thanks for the clarifications.

A note on the features removed in earlier rounds: those were optional extras
I had added as small refinements, not core functionality. When the
application was set to Needs work I removed them so they would not hold up the
review, not to avoid implementing them properly. With your clarification in
#21 -- that the correct path is to adjust the code to use the classes Drupal
core provides rather than drop the feature -- the Singular/Plural option has
been restored and re-implemented correctly (see the last section). The four
questions are answered below.

--- 1. The toConfig closure in SettingsForm ---

The "bundles" element is #type => checkboxes. On submit a checkboxes element
returns an associative array keyed by *every* option (checked and unchecked),
checked ones truthy and unchecked ones 0. Writing that straight to config is
wrong on three counts: it would persist unchecked bundles as 0 values (the
schema declares a sequence of enabled machine names), the order would follow
the options array rather than be stable, and a node type deleted between form
build and submit could still leak in.

The toConfig callback of ConfigTarget (the #config_target API, Drupal 10.2+)
maps the submitted form value to the stored config value:

- array_filter() drops the unchecked (falsy) entries;
- array_keys() keeps only the checked bundle machine names;
- array_intersect($valid_bundles, ...) keeps only names that still match an
existing node type (defends against a type removed after the form was built);
- array_values() reindexes to a clean list;
- sort() makes the output deterministic, so the exported
field_label_visibility.settings.yml is stable across environments and does
not produce noise diffs on config export.

So the closure exists to convert the checkboxes submitted shape into the
clean, validated, deterministic sequence the config schema declares. It is the
intended use of ConfigTarget::toConfig.

--- 2. Why preg_match() is compared to 1 ---

preg_match() returns int|false: 1 if the pattern matches, 0 if it does not,
and FALSE on an internal error (e.g. a malformed pattern). Comparing strictly
against 1 (and, for the negative case, !== 1) is the correct, type-safe way to
branch on "matched", because it treats both "no match" (0) and "error" (FALSE)
as "not a valid match", which is the safe default and avoids the classic
truthiness pitfall around the FALSE return.

The specific blocking validator that rejected '<' / '>' was redundant (the
label text is escaped with Html::escape() on output) and was removed in the
round-4 refactor. The only remaining preg_match() call is in sanitizeId();
this push aligns it to the explicit `=== 1` comparison for consistency with
this contract.

--- 3. #pattern plus a custom #element_validate handler ---

You are right that only one is usually necessary. #pattern is the HTML5
client-side pattern attribute: advisory only, trivially bypassed, never a
security or correctness boundary. #element_validate is the authoritative
server-side check. Having both was redundant; if a validator is warranted the
server-side #element_validate is the one to keep, with #pattern only as an
optional progressive-enhancement hint.

In this module neither is needed: the CSS classes are normalized at render
time with Html::cleanCssIdentifier(), the id is filtered through an allowlist
regex in sanitizeId(), and the label text is escaped with Html::escape(). So
both the #pattern attributes and the custom #element_validate callbacks were
removed and the values are sanitized at render time instead of blocking the
form.

--- 4. Alternatives to $this->t() in static methods ---

In a static method $this is unavailable, so the options are:

- the procedural t() function, which the Drupal coding standards explicitly
allow in static/procedural context such as a static #element_validate
callback (this is what the removed callback used);
- instantiating \Drupal\Core\StringTranslation\TranslatableMarkup directly;
- \Drupal::translation()->translate() / ->formatPlural() via the container;
- best of all, avoiding the static context: implement the logic on an
instance using StringTranslationTrait (so $this->t() / $this->formatPlural()
work) or inject TranslationInterface.

The module currently has no static translation call left (the static
validators were removed). The restored Singular/Plural resolution uses
$this->formatPlural() on the hook service instance, i.e. the last option.

--- Singular/Plural re-implemented (commit f3201a8) ---

The Singular/Plural toggle is restored. When enabled, the custom label is
written as "Singular|Plural" and the form to display is resolved by Drupal
core's PluralTranslatableMarkup through formatPlural(), so plural-form
selection follows the active language plural rules instead of the previous
English-only binary. The item count is taken from the field item list the
widget is bound to. Schema, settings-summary line and Kernel coverage
(single, zero, multi-value, default mode, toggle-off literal, summary) were
added accordingly.

Setting back to Needs review.