diff --git a/core/core.services.yml b/core/core.services.yml index c749b8e..654022a 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -601,7 +601,7 @@ services: - { name: string_translator, priority: 30 } string_translation: class: Drupal\Core\StringTranslation\TranslationManager - arguments: ['@language_manager'] + arguments: ['@language_manager', '@state'] calls: - [initLanguageManager] tags: diff --git a/core/lib/Drupal/Core/StringTranslation/StringTranslationTrait.php b/core/lib/Drupal/Core/StringTranslation/StringTranslationTrait.php index ebb20e4..1d7592b 100644 --- a/core/lib/Drupal/Core/StringTranslation/StringTranslationTrait.php +++ b/core/lib/Drupal/Core/StringTranslation/StringTranslationTrait.php @@ -64,6 +64,17 @@ protected function formatPluralTranslated($count, $translated, array $args = arr } /** + * Returns the number of plurals supported by a given language. + * + * See the + * \Drupal\Core\StringTranslation\TranslationInterface::getNumberOfPlurals() + * documentation for details. + */ + protected function getNumberOfPlurals($langcode = NULL) { + return $this->getStringTranslation()->getNumberOfPlurals($langcode); + } + + /** * Gets the string translation service. * * @return \Drupal\Core\StringTranslation\TranslationInterface diff --git a/core/lib/Drupal/Core/StringTranslation/TranslationInterface.php b/core/lib/Drupal/Core/StringTranslation/TranslationInterface.php index fbb28e1..7595029 100644 --- a/core/lib/Drupal/Core/StringTranslation/TranslationInterface.php +++ b/core/lib/Drupal/Core/StringTranslation/TranslationInterface.php @@ -120,4 +120,16 @@ public function formatPlural($count, $singular, $plural, array $args = array(), */ public function formatPluralTranslated($count, $translation, array $args = array(), array $options = array()); + /** + * Returns the number of plurals supported by a given language. + * + * @param null|string $langcode + * (optional) The language code. If not provided, the current language + * will be used. + * + * @return int + * Number of plural variants supported by the given language. + */ + public function getNumberOfPlurals($langcode = NULL); + } diff --git a/core/lib/Drupal/Core/StringTranslation/TranslationManager.php b/core/lib/Drupal/Core/StringTranslation/TranslationManager.php index edb391c..c653dad 100644 --- a/core/lib/Drupal/Core/StringTranslation/TranslationManager.php +++ b/core/lib/Drupal/Core/StringTranslation/TranslationManager.php @@ -9,6 +9,7 @@ use Drupal\Component\Utility\SafeMarkup; use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\State\StateInterface; use Drupal\Core\StringTranslation\Translator\TranslatorInterface; /** @@ -53,14 +54,24 @@ class TranslationManager implements TranslationInterface, TranslatorInterface { protected $defaultLangcode; /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** * Constructs a TranslationManager object. * - * @param \Drupal\Core\Language\LanguageManagerInterface + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager * The language manager. + * @param \Drupal\Core\State\StateInterface $state + * (optional) The state service. */ - public function __construct(LanguageManagerInterface $language_manager) { + public function __construct(LanguageManagerInterface $language_manager, StateInterface $state = NULL) { $this->languageManager = $language_manager; $this->defaultLangcode = $language_manager->getDefaultLanguage()->getId(); + $this->state = $state; } /** @@ -229,4 +240,21 @@ public function reset() { } } + /** + * @inheritdoc. + */ + public function getNumberOfPlurals($langcode = NULL) { + // If the state service is not injected, we assume 2 plural variants are + // allowed. This may happen in the installer for simplicity. We also assume + // 2 plurals if there is no explicit information yet. + if (isset($this->state)) { + $langcode = $langcode ?: $this->languageManager->getCurrentLanguage()->getId(); + $plural_formulas = $this->state->get('locale.translation.plurals') ?: array(); + if (isset($plural_formulas[$langcode]['plurals'])) { + return $plural_formulas[$langcode]['plurals']; + } + } + return 2; + } + } diff --git a/core/modules/config_translation/src/FormElement/FormElementBase.php b/core/modules/config_translation/src/FormElement/FormElementBase.php index 7db01e1..e13cc31 100644 --- a/core/modules/config_translation/src/FormElement/FormElementBase.php +++ b/core/modules/config_translation/src/FormElement/FormElementBase.php @@ -91,6 +91,7 @@ public function getTranslationBuild(LanguageInterface $source_language, Language * A render array for the source value. */ protected function getSourceElement(LanguageInterface $source_language, $source_config) { + // @todo Should support singular+plurals https://www.drupal.org/node/2454829 if ($source_config) { $value = '' . nl2br($source_config) . ''; } @@ -161,6 +162,7 @@ protected function getSourceElement(LanguageInterface $source_language, $source_ */ protected function getTranslationElement(LanguageInterface $translation_language, $source_config, $translation_config) { // Add basic properties that apply to all form elements. + // @todo Should support singular+plurals https://www.drupal.org/node/2454829 return array( '#title' => $this->t('!label (!source_language)', array( '!label' => $this->t($this->definition['label']), diff --git a/core/modules/file/config/optional/views.view.files.yml b/core/modules/file/config/optional/views.view.files.yml index 6783c1b..526d9fd 100644 --- a/core/modules/file/config/optional/views.view.files.yml +++ b/core/modules/file/config/optional/views.view.files.yml @@ -529,8 +529,7 @@ display: decimal: . separator: ',' format_plural: true - format_plural_singular: '1 place' - format_plural_plural: '@count places' + format_plural_string: "1 place\x03@count places" prefix: '' suffix: '' plugin_id: numeric @@ -952,8 +951,7 @@ display: decimal: . separator: ',' format_plural: false - format_plural_singular: '1' - format_plural_plural: '@count' + format_plural_string: "1\x03@count" prefix: '' suffix: '' plugin_id: numeric diff --git a/core/modules/forum/tests/modules/forum_test_views/test_views/views.view.test_forum_index.yml b/core/modules/forum/tests/modules/forum_test_views/test_views/views.view.test_forum_index.yml index 1f917f0..32a9306 100644 --- a/core/modules/forum/tests/modules/forum_test_views/test_views/views.view.test_forum_index.yml +++ b/core/modules/forum/tests/modules/forum_test_views/test_views/views.view.test_forum_index.yml @@ -141,8 +141,7 @@ display: decimal: . separator: ',' format_plural: false - format_plural_singular: '1' - format_plural_plural: '@count' + format_plural_string: "1\x03@count" prefix: '' suffix: '' plugin_id: numeric diff --git a/core/modules/locale/src/Form/TranslateEditForm.php b/core/modules/locale/src/Form/TranslateEditForm.php index 9a16fb7..f8e9938 100644 --- a/core/modules/locale/src/Form/TranslateEditForm.php +++ b/core/modules/locale/src/Form/TranslateEditForm.php @@ -58,7 +58,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { if (isset($langcode)) { $strings = $this->translateFilterLoadStrings(); - $plural_formulas = $this->state->get('locale.translation.plurals') ?: array(); + $plurals = $this->getNumberOfPlurals($langcode); foreach ($strings as $string) { // Cast into source string, will do for our purposes. @@ -119,38 +119,21 @@ public function buildForm(array $form, FormStateInterface $form_state) { ); } else { - // Dealing with plural strings. - if (isset($plural_formulas[$langcode]['plurals']) && $plural_formulas[$langcode]['plurals'] > 2) { - // Add a textarea for each plural variant. - for ($i = 0; $i < $plural_formulas[$langcode]['plurals']; $i++) { - $form['strings'][$string->lid]['translations'][$i] = array( - '#type' => 'textarea', - '#title' => ($i == 0 ? $this->t('Singular form') : $this->formatPlural($i, 'First plural form', '@count. plural form')), - '#rows' => $rows, - '#default_value' => isset($translation_array[$i]) ? $translation_array[$i] : '', - '#attributes' => array('lang' => $langcode), - '#prefix' => $i == 0 ? ('' . $this->t('Translated string (@language)', array('@language' => $langname)) . '') : '', - ); - } - } - else { - // Fallback for unknown number of plurals. - $form['strings'][$string->lid]['translations'][0] = array( - '#type' => 'textarea', - '#title' => $this->t('Singular form'), - '#rows' => $rows, - '#default_value' => $translation_array[0], - '#attributes' => array('lang' => $langcode), - '#prefix' => '' . $this->t('Translated string (@language)', array('@language' => $langname)) . '', - ); - $form['strings'][$string->lid]['translations'][1] = array( + // Add a textarea for each plural variant. + for ($i = 0; $i < $plurals; $i++) { + $form['strings'][$string->lid]['translations'][$i] = array( '#type' => 'textarea', - '#title' => $this->t('Plural form'), + '#title' => ($i == 0 ? $this->t('Singular form') : $this->formatPlural($i, 'First plural form', '@count. plural form')), '#rows' => $rows, - '#default_value' => isset($translation_array[1]) ? $translation_array[1] : '', + '#default_value' => isset($translation_array[$i]) ? $translation_array[$i] : '', '#attributes' => array('lang' => $langcode), + '#prefix' => $i == 0 ? ('' . $this->t('Translated string (@language)', array('@language' => $langname)) . '') : '', ); } + if ($plurals == 2) { + // Simplify user interface text for the most common case. + $form['strings'][$string->lid]['translations'][1]['title'] = $this->t('Plural form'); + } } } if (count(Element::children($form['strings']))) { diff --git a/core/modules/statistics/tests/modules/statistics_test_views/test_views/views.view.test_statistics_integration.yml b/core/modules/statistics/tests/modules/statistics_test_views/test_views/views.view.test_statistics_integration.yml index 0b0e227..b8d3c4a 100644 --- a/core/modules/statistics/tests/modules/statistics_test_views/test_views/views.view.test_statistics_integration.yml +++ b/core/modules/statistics/tests/modules/statistics_test_views/test_views/views.view.test_statistics_integration.yml @@ -160,8 +160,7 @@ display: decimal: . separator: '' format_plural: false - format_plural_singular: '1' - format_plural_plural: '@count' + format_plural_string: "1\x03@count" prefix: '' suffix: '' plugin_id: numeric @@ -218,8 +217,7 @@ display: decimal: . separator: '' format_plural: false - format_plural_singular: '1' - format_plural_plural: '@count' + format_plural_string: "1\x03@count" prefix: '' suffix: '' plugin_id: numeric diff --git a/core/modules/views/config/schema/views.field.schema.yml b/core/modules/views/config/schema/views.field.schema.yml index c6bf6b3..de8ffcb 100644 --- a/core/modules/views/config/schema/views.field.schema.yml +++ b/core/modules/views/config/schema/views.field.schema.yml @@ -116,12 +116,9 @@ views.field.numeric: format_plural: type: boolean label: 'Format plural' - format_plural_singular: + format_plural_string: type: label - label: 'Singular form' - format_plural_plural: - type: label - label: 'Plural form' + label: 'Singular and one or more plurals' prefix: type: label label: 'Prefix' diff --git a/core/modules/views/src/Plugin/views/field/NumericField.php b/core/modules/views/src/Plugin/views/field/NumericField.php index 5e2db03..dbf02d9 100644 --- a/core/modules/views/src/Plugin/views/field/NumericField.php +++ b/core/modules/views/src/Plugin/views/field/NumericField.php @@ -34,8 +34,7 @@ protected function defineOptions() { $options['decimal'] = array('default' => '.'); $options['separator'] = array('default' => ','); $options['format_plural'] = array('default' => FALSE); - $options['format_plural_singular'] = array('default' => '1'); - $options['format_plural_plural'] = array('default' => '@count'); + $options['format_plural_string'] = array('default' => '1' . LOCALE_PLURAL_DELIMITER . '@count'); $options['prefix'] = array('default' => ''); $options['suffix'] = array('default' => ''); @@ -93,28 +92,33 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { '#description' => $this->t('If checked, special handling will be used for plurality.'), '#default_value' => $this->options['format_plural'], ); - $form['format_plural_singular'] = array( - '#type' => 'textfield', - '#title' => $this->t('Singular form'), - '#default_value' => $this->options['format_plural_singular'], - '#description' => $this->t('Text to use for the singular form.'), - '#states' => array( - 'visible' => array( - ':input[name="options[format_plural]"]' => array('checked' => TRUE), - ), - ), + $form['format_plural_string'] = array( + '#type' => 'value', + '#default_value' => $this->options['format_plural_string'], ); - $form['format_plural_plural'] = array( - '#type' => 'textfield', - '#title' => $this->t('Plural form'), - '#default_value' => $this->options['format_plural_plural'], - '#description' => $this->t('Text to use for the plural form, @count will be replaced with the value.'), - '#states' => array( - 'visible' => array( - ':input[name="options[format_plural]"]' => array('checked' => TRUE), + + $plural_array = explode(LOCALE_PLURAL_DELIMITER, $this->options['format_plural_string']); + $plurals = $this->getNumberOfPlurals($this->view->storage->get('langcode')); + for ($i = 0; $i < $plurals; $i++) { + $form['format_plural_values'][$i] = array( + '#type' => 'textfield', + '#title' => ($i == 0 ? $this->t('Singular form') : $this->formatPlural($i, 'First plural form', '@count. plural form')), + '#default_value' => isset($plural_array[$i]) ? $plural_array[$i] : '', + '#description' => $this->t('Text to use for this variant, @count will be replaced with the value.'), + '#states' => array( + 'visible' => array( + ':input[name="options[format_plural]"]' => array('checked' => TRUE), + ), ), - ), - ); + ); + } + if ($plurals == 2) { + // Simplify user interface text for the most common case. + $form['format_plural_values'][0]['description'] = $this->t('Text to use for the singular form, @count will be replaced with the value.'); + $form['format_plural_values'][1]['title'] = $this->t('Plural form'); + $form['format_plural_values'][1]['description'] = $this->t('Text to use for the plural form, @count will be replaced with the value.'); + } + $form['prefix'] = array( '#type' => 'textfield', '#title' => $this->t('Prefix'), @@ -132,6 +136,18 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { } /** + * @inheritdoc + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + // Merge plural format options into one string and drop the individual + // option values. + $options = &$form_state->getValue('options'); + $options['format_plural_string'] = implode(LOCALE_PLURAL_DELIMITER, $options['format_plural_values']); + unset($options['format_plural_values']); + parent::submitOptionsForm($form, $form_state); + } + + /** * {@inheritdoc} */ public function render(ResultRow $values) { @@ -154,9 +170,10 @@ public function render(ResultRow $values) { return ''; } - // Should we format as a plural. + // If we should format as plural, take the (possibly) translated plural + // setting and format with the current language. if (!empty($this->options['format_plural'])) { - $value = $this->formatPlural($value, $this->options['format_plural_singular'], $this->options['format_plural_plural']); + $value = $this->formatPluralTranslated($value, $this->options['format_plural_string']); } return $this->sanitizeValue($this->options['prefix'], 'xss') diff --git a/core/modules/views/src/Tests/Plugin/NumericFormatPluralTest.php b/core/modules/views/src/Tests/Plugin/NumericFormatPluralTest.php new file mode 100644 index 0000000..870342b --- /dev/null +++ b/core/modules/views/src/Tests/Plugin/NumericFormatPluralTest.php @@ -0,0 +1,170 @@ +web_user = $this->drupalCreateUser(array('administer views', 'administer languages')); + $this->drupalLogin($this->web_user); + } + + /** + * Test plural formatting setting on a numeric views handler. + */ + function testNumericFormatPlural() { + // Create a file. + $file = $this->createFile(); + + // Assert that the starting configuration is correct. + $config = $this->config('views.view.numeric_test'); + $field_config_prefix = 'display.default.display_options.fields.count.'; + $this->assertEqual($config->get($field_config_prefix . 'format_plural'), TRUE); + $this->assertEqual($config->get($field_config_prefix . 'format_plural_string'), '1' . LOCALE_PLURAL_DELIMITER . '@count'); + + // Assert that the value is displayed. + $this->drupalGet('numeric-test'); + $this->assertRaw('0'); + + // Assert that the user interface has controls to change it. + $this->drupalGet('admin/structure/views/nojs/handler/numeric_test/page_1/field/count'); + $this->assertFieldByName('options[format_plural_values][0]', '1'); + $this->assertFieldByName('options[format_plural_values][1]', '@count'); + + // Assert that changing the settings will change configuration properly. + $edit = ['options[format_plural_values][0]' => '1 time', 'options[format_plural_values][1]' => '@count times']; + $this->drupalPostForm(NULL, $edit, t('Apply')); + $this->drupalPostForm(NULL, array(), t('Save')); + + $config = $this->config('views.view.numeric_test'); + $field_config_prefix = 'display.default.display_options.fields.count.'; + $this->assertEqual($config->get($field_config_prefix . 'format_plural'), TRUE); + $this->assertEqual($config->get($field_config_prefix . 'format_plural_string'), '1 time' . LOCALE_PLURAL_DELIMITER . '@count times'); + + // Assert that the value is displayed with some sample values. + $numbers = [0, 1, 2, 3, 4, 42]; + foreach ($numbers as $i => $number) { + \Drupal::service('file.usage')->add($file, 'views_ui', 'dummy', $i, $number); + } + $this->drupalGet('numeric-test'); + foreach ($numbers as $i => $number) { + $this->assertRaw('' . $number . ($number == 1 ? ' time' : ' times') . ''); + } + + // Add Slovenian and set its plural formula to test multiple plural forms. + $edit = ['predefined_langcode' => 'sl']; + $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language')); + $formula = 'nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);'; + $header = new PoHeader(); + list($nplurals, $formula) = $header->parsePluralForms($formula); + \Drupal::state()->set('locale.translation.plurals', ['sl' => ['plurals' => $nplurals, 'formula' => $formula]]); + + // Change the view to Slovenian. + $config = $this->config('views.view.numeric_test'); + $config->set('langcode', 'sl')->save(); + + // Assert that the user interface has controls with more inputs now. + $this->drupalGet('admin/structure/views/nojs/handler/numeric_test/page_1/field/count'); + $this->assertFieldByName('options[format_plural_values][0]', '1 time'); + $this->assertFieldByName('options[format_plural_values][1]', '@count times'); + $this->assertFieldByName('options[format_plural_values][2]', ''); + $this->assertFieldByName('options[format_plural_values][3]', ''); + + // Assert that changing the settings will change configuration properly. + $edit = [ + 'options[format_plural_values][0]' => '@count time0', + 'options[format_plural_values][1]' => '@count time1', + 'options[format_plural_values][2]' => '@count time2', + 'options[format_plural_values][3]' => '@count time3', + ]; + $this->drupalPostForm(NULL, $edit, t('Apply')); + $this->drupalPostForm(NULL, array(), t('Save')); + $config = $this->config('views.view.numeric_test'); + $field_config_prefix = 'display.default.display_options.fields.count.'; + $this->assertEqual($config->get($field_config_prefix . 'format_plural'), TRUE); + $this->assertEqual($config->get($field_config_prefix . 'format_plural_string'), implode(LOCALE_PLURAL_DELIMITER, array_values($edit))); + + // The view should now use the new plural configuration. + $this->drupalGet('sl/numeric-test'); + $this->assertRaw('0 time3'); + $this->assertRaw('1 time0'); + $this->assertRaw('2 time1'); + $this->assertRaw('3 time2'); + $this->assertRaw('4 time2'); + $this->assertRaw('42 time3'); + + // Add an English configuration translation with English plurals. + $english = \Drupal::languageManager()->getLanguageConfigOverride('en', 'views.view.numeric_test'); + $english->set('display.default.display_options.fields.count.format_plural_string', '1 time' . LOCALE_PLURAL_DELIMITER . '@count times')->save(); + + // The view displayed in English should use the English translation. + $this->drupalGet('numeric-test'); + $this->assertRaw('0 times'); + $this->assertRaw('1 time'); + $this->assertRaw('2 times'); + $this->assertRaw('3 times'); + $this->assertRaw('4 times'); + $this->assertRaw('42 times'); + } + + /** + * Creates and saves a test file. + * + * @return \Drupal\Core\Entity\EntityInterface + * A file entity. + */ + protected function createFile() { + // Create a new file entity. + $file = entity_create('file', array( + 'uid' => 1, + 'filename' => 'druplicon.txt', + 'uri' => 'public://druplicon.txt', + 'filemime' => 'text/plain', + 'created' => 1, + 'changed' => 1, + 'status' => FILE_STATUS_PERMANENT, + )); + file_put_contents($file->getFileUri(), 'hello world'); + + // Save it, inserting a new record. + $file->save(); + + return $file; + } +} diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.numeric_test.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.numeric_test.yml new file mode 100644 index 0000000..e8e99ad --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.numeric_test.yml @@ -0,0 +1,189 @@ +uuid: 6f602122-2918-44c7-8b05-5d6c1e93e6ac +langcode: en +status: true +dependencies: + module: + - file + - user +id: numeric_test +label: 'Numeric test' +module: views +description: '' +tag: '' +base_table: file_managed +base_field: fid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'administer views' + cache: + type: none + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: '‹ previous' + next: 'next ›' + first: '« first' + last: 'last »' + quantity: 9 + style: + type: default + row: + type: fields + fields: + filename: + id: filename + table: file_managed + field: filename + entity_type: file + entity_field: filename + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + link_to_file: true + plugin_id: file + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + count: + id: count + table: file_usage + field: count + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + set_precision: false + precision: 0 + decimal: . + separator: ',' + format_plural: true + format_plural_string: "1\x03@count" + prefix: '' + suffix: '' + plugin_id: numeric + filters: { } + sorts: { } + title: 'Numeric test' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + contexts: + - language + cacheable: false + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: numeric-test + cache_metadata: + contexts: + - language + cacheable: false