diff --git a/includes/common.inc b/includes/common.inc index f2db46b28113abd5e467b9a9c06f28c5805ef94c..878e8106de2f5b5f2d04302f42cfd4daf4b545d3 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -3094,6 +3094,79 @@ function drupal_clear_css_cache() { file_scan_directory('public://css', '/.*/', array('callback' => 'file_unmanaged_delete')); } +/** + * Prepare a string for use as a valid CSS identifier (element, class or ID selector). + * + * http://www.w3.org/TR/CSS21/syndata.html#characters shows the syntax for valid + * CSS identifiers (including element names, classes, and IDs in selectors.) + * + * @param $identifier + * The CSS identifier to clean. + * @param $filter + * An array of string replacements to use on the identifier. + * @return + * The cleaned identifier. + */ +function drupal_clean_css_identifier($identifier, $filter = array(' ' => '-', '_' => '-', '[' => '-', ']' => '')) { + // By default, we filter using Drupal's coding standards. + $identifier = strtr($identifier, $filter); + + // Valid characters in a CSS identifier are: + // - the hyphen (U+002D) + // - a-z (U+0030 - U+0039) + // - A-Z (U+0041 - U+005A) + // - the underscore (U+005F) + // - 0-9 (U+0061 - U+007A) + // - ISO 10646 characters U+00A1 and higher + // We strip out any character not in the above list. + $identifier = preg_replace('/[^\x{002D}\x{0030}-\x{0039}\x{0041}-\x{005A}\x{005F}\x{0061}-\x{007A}\x{00A1}-\x{FFFF}]/u', '', $identifier); + + return $identifier; +} + +/** + * Prepare a string for use as a valid CSS class name. + * + * Do not pass one string containing multiple classes as they will be + * incorrectly concatenated with dashes, i.e. "one two" will become "one-two". + * + * @param $class + * The class name to clean. + * @return + * The cleaned class name. + */ +function drupal_css_class($class) { + return drupal_clean_css_identifier(drupal_strtolower($class)); +} + +/** + * Prepare a string for use as a valid HTML ID and guarantee uniqueness. + * + * @param $id + * The ID to clean. + * @return + * The cleaned ID. + */ +function drupal_css_id($id) { + $seen_ids = &drupal_static(__FUNCTION__, array()); + $id = drupal_clean_css_identifier(drupal_strtolower($id)); + + // Ensure IDs are unique. The first occurrence is held but left alone. + // Subsequent occurrences get a number appended to them. This incrementing + // will almost certainly break code that relies on explicit HTML IDs in forms + // that appear more than once on the page, but the alternative is outputting + // duplicate IDs, which would break JS code and XHTML validity anyways. For + // now, it's an acceptable stopgap solution. + if (isset($seen_ids[$id])) { + $id = $id . '-' . ++$seen_ids[$id]; + } + else { + $seen_ids[$id] = 1; + } + + return $id; +} + /** * Add a JavaScript file, setting or inline code to the page. * diff --git a/includes/form.inc b/includes/form.inc index 3ee8af7b77e5140749d9332151ae38b8243d730b..82d84b4f8f5c7636bf5d221ff918adf4a77bc885 100644 --- a/includes/form.inc +++ b/includes/form.inc @@ -547,12 +547,12 @@ function drupal_process_form($form_id, &$form, &$form_state) { if ($form_state['process_input']) { drupal_validate_form($form_id, $form, $form_state); - // form_clean_id() maintains a cache of element IDs it has seen, + // drupal_css_id() maintains a cache of element IDs it has seen, // so it can prevent duplicates. We want to be sure we reset that // cache when a form is processed, so scenarios that result in // the form being built behind the scenes and again for the // browser don't increment all the element IDs needlessly. - drupal_static_reset('form_clean_id'); + drupal_static_reset('drupal_css_id'); if ($form_state['submitted'] && !form_get_errors() && !$form_state['rebuild']) { // Execute form submit handlers. @@ -636,7 +636,7 @@ function drupal_prepare_form($form_id, &$form, &$form_state) { elseif (isset($user->uid) && $user->uid && !$form_state['programmed']) { $form['#token'] = $form_id; $form['form_token'] = array( - '#id' => form_clean_id('edit-' . $form_id . '-form-token'), + '#id' => drupal_css_id('edit-' . $form_id . '-form-token'), '#type' => 'token', '#default_value' => drupal_get_token($form['#token']), ); @@ -646,11 +646,11 @@ function drupal_prepare_form($form_id, &$form, &$form_state) { $form['form_id'] = array( '#type' => 'hidden', '#value' => $form_id, - '#id' => form_clean_id("edit-$form_id"), + '#id' => drupal_css_id("edit-$form_id"), ); } if (!isset($form['#id'])) { - $form['#id'] = form_clean_id($form_id); + $form['#id'] = drupal_css_id($form_id); } $form += element_info('form'); @@ -1046,7 +1046,7 @@ function form_builder($form_id, $element, &$form_state) { } if (!isset($element['#id'])) { - $element['#id'] = form_clean_id('edit-' . implode('-', $element['#parents'])); + $element['#id'] = drupal_css_id('edit-' . implode('-', $element['#parents'])); } // Handle input elements. if (!empty($element['#input'])) { @@ -1919,7 +1919,7 @@ function form_process_radios($element) { '#default_value' => isset($element['#default_value']) ? $element['#default_value'] : NULL, '#attributes' => $element['#attributes'], '#parents' => $element['#parents'], - '#id' => form_clean_id('edit-' . implode('-', $parents_for_id)), + '#id' => drupal_css_id('edit-' . implode('-', $parents_for_id)), '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL, ); } @@ -2235,7 +2235,7 @@ function form_process_tableselect($element) { '#default_value' => ($element['#default_value'] == $key) ? $key : NULL, '#attributes' => $element['#attributes'], '#parents' => $element['#parents'], - '#id' => form_clean_id('edit-' . implode('-', $parents_for_id)), + '#id' => drupal_css_id('edit-' . implode('-', $parents_for_id)), '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL, ); } @@ -2683,42 +2683,6 @@ function _form_set_class(&$element, $class = array()) { } } -/** - * Prepare an HTML ID attribute string for a form item. - * - * Remove invalid characters and guarantee uniqueness. - * - * @param $id - * The ID to clean. - * @param $flush - * If set to TRUE, the function will flush and reset the static array - * which is built to test the uniqueness of element IDs. This is only - * used if a form has completed the validation process. This parameter - * should never be set to TRUE if this function is being called to - * assign an ID to the #ID element. - * @return - * The cleaned ID. - */ -function form_clean_id($id = NULL) { - $seen_ids = &drupal_static(__FUNCTION__, array()); - $id = str_replace(array('][', '_', ' '), '-', $id); - - // Ensure IDs are unique. The first occurrence is held but left alone. - // Subsequent occurrences get a number appended to them. This incrementing - // will almost certainly break code that relies on explicit HTML IDs in - // forms that appear more than once on the page, but the alternative is - // outputting duplicate IDs, which would break JS code and XHTML - // validity anyways. For now, it's an acceptable stopgap solution. - if (isset($seen_ids[$id])) { - $id = $id . '-' . $seen_ids[$id]++; - } - else { - $seen_ids[$id] = 1; - } - - return $id; -} - /** * @} End of "defgroup form_api". */ diff --git a/includes/theme.inc b/includes/theme.inc index 71fbf9ba7a0be609451bb8a5bc7a9a8efd1c8e56..ca0e533aa77828e18091f32d87cd2f8bfa84ce54 100644 --- a/includes/theme.inc +++ b/includes/theme.inc @@ -2128,18 +2128,17 @@ function template_preprocess_html(&$variables) { if ($suggestions = template_page_suggestions(arg(), 'page')) { foreach ($suggestions as $suggestion) { if ($suggestion != 'page-front') { - // Add current suggestion to page classes to make it possible to theme the page - // depending on the current page type (e.g. node, admin, user, etc.) as well as - // more specific data like node-12 or node-edit. To avoid illegal characters in - // the class, we're removing everything disallowed. We are not using 'a-z' as - // that might leave in certain international characters (e.g. German umlauts). - $variables['classes_array'][] = preg_replace('![^abcdefghijklmnopqrstuvwxyz0-9-_]+!s', '', form_clean_id(drupal_strtolower($suggestion))); + // Add current suggestion to page classes to make it possible to theme + // the page depending on the current page type (e.g. node, admin, user, + // etc.) as well as more specific data like node-12 or node-edit. + $variables['classes_array'][] = drupal_css_class($suggestion); } } } + // If on an individual node page, add the node type to body classes. if ($node = menu_get_object()) { - $variables['classes_array'][] = 'node-type-' . form_clean_id($node->type); + $variables['classes_array'][] = drupal_css_class('node-type-' . $node->type); } // RDFa allows annotation of XHTML pages with RDF data, while GRDDL provides @@ -2150,7 +2149,6 @@ function template_preprocess_html(&$variables) { $variables['language'] = $GLOBALS['language']; $variables['language']->dir = $GLOBALS['language']->direction ? 'rtl' : 'ltr'; - // Add favicon. if (theme_get_setting('toggle_favicon')) { $favicon = theme_get_setting('favicon'); diff --git a/modules/block/block.module b/modules/block/block.module index 66aed9651f824224af3538b0d3047a4fe6f1a65e..14488fe1eb2562d56f5dbec33fc9e267326fccbc 100644 --- a/modules/block/block.module +++ b/modules/block/block.module @@ -797,7 +797,7 @@ function template_preprocess_block(&$variables) { // Create the $content variable that templates expect. $variables['content'] = $variables['elements']['#children']; - $variables['classes_array'][] = 'block-' . $variables['block']->module; + $variables['classes_array'][] = drupal_css_class('block-' . $variables['block']->module); $variables['template_files'][] = 'block-' . $variables['block']->region; $variables['template_files'][] = 'block-' . $variables['block']->module; diff --git a/modules/filter/filter.module b/modules/filter/filter.module index 8b9494d456b19708042ccc167907db39a9f8370c..1424827310856165be0d11086fa454a8821222a4 100644 --- a/modules/filter/filter.module +++ b/modules/filter/filter.module @@ -746,7 +746,7 @@ function filter_form($selected_format = NULL, $weight = NULL, $parents = array(' drupal_add_js('misc/form.js'); drupal_add_css(drupal_get_path('module', 'filter') . '/filter.css'); - $element_id = form_clean_id('edit-' . implode('-', $parents)); + $element_id = drupal_css_id('edit-' . implode('-', $parents)); $form = array( '#type' => 'fieldset', diff --git a/modules/node/node.module b/modules/node/node.module index bd99ccc4273518398cc91744680eab8b18974d30..20187b30bd18466a161a346b69e56bf9b177ae09 100644 --- a/modules/node/node.module +++ b/modules/node/node.module @@ -1195,7 +1195,7 @@ function template_preprocess_node(&$variables) { } // Gather node classes. - $variables['classes_array'][] = 'node-' . $node->type; + $variables['classes_array'][] = drupal_css_class('node-' . $node->type); if ($variables['promote']) { $variables['classes_array'][] = 'node-promoted'; } diff --git a/modules/profile/profile.test b/modules/profile/profile.test index 92b9ee428a1bd0d7c187e795a543784a0bbb36c3..507d5ebd371cd3fa92a124bc2bacbfebd91054fe 100644 --- a/modules/profile/profile.test +++ b/modules/profile/profile.test @@ -266,8 +266,8 @@ class ProfileTestAutocomplete extends ProfileTestCase { $this->setProfileField($field, $field['value']); // Set some html for what we want to see in the page output later. - $autocomplete_html = '<input class="autocomplete" type="hidden" id="' . form_clean_id('edit-' . $field['form_name'] . '-autocomplete') . '" value="' . url('profile/autocomplete/' . $field['fid'], array('absolute' => TRUE)) . '" disabled="disabled" />'; - $field_html = '<input type="text" maxlength="255" name="' . $field['form_name'] . '" id="' . form_clean_id('edit-' . $field['form_name']) . '" size="60" value="' . $field['value'] . '" class="form-text form-autocomplete required" />'; + $autocomplete_html = '<input class="autocomplete" type="hidden" id="' . drupal_css_id('edit-' . $field['form_name'] . '-autocomplete') . '" value="' . url('profile/autocomplete/' . $field['fid'], array('absolute' => TRUE)) . '" disabled="disabled" />'; + $field_html = '<input type="text" maxlength="255" name="' . $field['form_name'] . '" id="' . drupal_css_id('edit-' . $field['form_name']) . '" size="60" value="' . $field['value'] . '" class="form-text form-autocomplete required" />'; // Check that autocompletion html is found on the user's profile edit page. $this->drupalGet('user/' . $this->admin_user->uid . '/edit/' . $category); diff --git a/modules/simpletest/tests/common.test b/modules/simpletest/tests/common.test index 123fc1b85987e567f0854f53ca9d76056bd8b0c2..72f92820d7b5e0760d7e82d8b028c43a2d97c88e 100644 --- a/modules/simpletest/tests/common.test +++ b/modules/simpletest/tests/common.test @@ -572,6 +572,59 @@ class CascadingStylesheetsTestCase extends DrupalWebTestCase { } } +/** + * Test for drupal_add_css(). + */ +class DrupalCSSIdentifierTestCase extends DrupalUnitTestCase { + public static function getInfo() { + return array( + 'name' => 'CSS identifiers', + 'description' => 'Test the functions drupal_css_class() and drupal_css_id() for expected behavior', + 'group' => 'System', + ); + } + + /** + * Tests that drupal_css_class() cleans the class name properly. + */ + function testDrupalCleanCSSIdentifier() { + // Verify that no valid ASCII characters are stripped from the class name. + $class = 'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789'; + $this->assertIdentical(drupal_clean_css_identifier($class, array()), $class, t('Verify valid ASCII characters pass through.')); + + // Verify that no valid UTF-8 characters are stripped from the class name. + $class = '¡¢£¤¥'; + $this->assertIdentical(drupal_clean_css_identifier($class, array()), $class, t('Verify valid UTF-8 characters pass through.')); + + // Verify that invalid characters (including non-breaking space) are stripped from the class name. + $this->assertIdentical(drupal_clean_css_identifier('invalid !"#$%&\'()*+,./:;<=>?@[\\]^`{|}~ class', array()), 'invalidclass', t('Strip invalid characters.')); + } + + /** + * Tests that drupal_css_class() cleans the class name properly. + */ + function testDrupalCSSClass() { + // Verify Drupal coding standards are enforced. + $this->assertIdentical(drupal_css_class('CLASS NAME_[Ü]'), 'class-name--ü', t('Enforce Drupal coding standards.')); + } + + /** + * Tests that drupal_css_id() cleans the id name properly. + */ + function testDrupalCSSId() { + // Verify Drupal coding standards are enforced. + $this->assertIdentical(drupal_css_id('ID NAME_[Ü]'), 'id-name--ü', t('Enforce Drupal coding standards.')); + + // Reset the static cache so we can ensure the unique id count is at zero. + drupal_static_reset('drupal_css_id'); + + // Clean up IDs with invalid starting characters. + $this->assertIdentical(drupal_css_id('test-unique-id'), 'test-unique-id', t('Test the uniqueness of IDs #1.')); + $this->assertIdentical(drupal_css_id('test-unique-id'), 'test-unique-id-2', t('Test the uniqueness of IDs #2.')); + $this->assertIdentical(drupal_css_id('test-unique-id'), 'test-unique-id-3', t('Test the uniqueness of IDs #3.')); + } +} + /** * Test drupal_http_request(). */ diff --git a/modules/simpletest/tests/form.test b/modules/simpletest/tests/form.test index 855fb3d081fe3f69e6ca54151a347fbfe65eb243..fb922ec8cec9276112491c8c6a2d5fc7d3356151 100644 --- a/modules/simpletest/tests/form.test +++ b/modules/simpletest/tests/form.test @@ -332,36 +332,6 @@ class FormsElementsTableSelectFunctionalTest extends DrupalWebTestCase { } -/** - * Test the form_clean_id() for expected behavior. - */ -class FormsFormCleanIdFunctionalTest extends DrupalWebTestCase { - - public static function getInfo() { - return array( - 'name' => 'form_clean_id() test', - 'description' => 'Test the function form_clean_id() for expected behavior', - 'group' => 'Form API', - ); - } - - function setUp() { - parent::setUp('form_test'); - } - - /** - * Test the uniqueness of the form_clean_id() function. - */ - function testFormCleanId() { - $this->drupalGet('form_test/form_clean_id'); - $this->assertNoUniqueText('form-test-form-clean-id-presence'); - $this->assertUniqueText('form-test-form-clean-id-presence-1'); - $this->assertUniqueText('form-test-form-clean-id-presence-2'); - $this->assertNoUniqueText('Test Textfield'); - } - -} - /** * Test using drupal_form_submit in a batch. */ diff --git a/modules/simpletest/tests/form_test.module b/modules/simpletest/tests/form_test.module index 6d6fd38f82fc69cfb57faca7e30fb3f736516d8d..ee47b4f1429d1cf4e5ff205560739945284b53a5 100644 --- a/modules/simpletest/tests/form_test.module +++ b/modules/simpletest/tests/form_test.module @@ -39,13 +39,6 @@ function form_test_menu() { 'type' => MENU_CALLBACK, ); - $items['form_test/form_clean_id'] = array( - 'title' => 'form_clean_id test', - 'page callback' => 'form_test_form_clean_id_page', - 'access arguments' => array('access content'), - 'type' => MENU_CALLBACK, - ); - $items['form_test/drupal_form_submit_batch_api'] = array( 'title' => 'BatchAPI Drupal_form_submit tests', 'page callback' => 'form_test_drupal_form_submit_batch_api', @@ -72,28 +65,6 @@ function form_test_menu() { return $items; } -/** - * Generate a page with three forms, to test the clean_id generation. - */ -function form_test_form_clean_id_page() { - $build['form_test_test_form1'] = drupal_get_form('form_test_test_form'); - $build['form_test_test_form2'] = drupal_get_form('form_test_test_form'); - $build['form_test_test_form3'] = drupal_get_form('form_test_test_form'); - return $build; -} - -/** - * A simple form to test clean_id generation. - */ -function form_test_test_form($form, &$form_state) { - $form['input'] = array( - '#type' => 'item', - '#title' => 'Test Textfield', - '#markup' => form_clean_id('form_test_form_clean_id_presence'), - ); - return $form; -} - /** * Create a header and options array. Helper function for callbacks. */ diff --git a/modules/system/system.test b/modules/system/system.test index 9df53b78fea3c00e9f7f54a94bbdea98123746f6..686d6cdb9cb258672ddfb609541b19a70c659b33 100644 --- a/modules/system/system.test +++ b/modules/system/system.test @@ -252,7 +252,7 @@ class ModuleVersionTestCase extends ModuleTestCase { $n = count($dependencies); for ($i = 0; $i < $n; $i++) { $this->drupalGet('admin/config/modules'); - $checkbox = $this->xpath('//input[@id="edit-modules-Testing-module-test-enable"]'); + $checkbox = $this->xpath('//input[@id="edit-modules-testing-module-test-enable"]'); $this->assertEqual(!empty($checkbox[0]['disabled']), $i % 2, $dependencies[$i]); } }