class-wp-customize-custom-css-setting.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. <?php
  2. /**
  3. * Customize API: WP_Customize_Custom_CSS_Setting class
  4. *
  5. * This handles validation, sanitization and saving of the value.
  6. *
  7. * @package WordPress
  8. * @subpackage Customize
  9. * @since 4.7.0
  10. */
  11. /**
  12. * Custom Setting to handle WP Custom CSS.
  13. *
  14. * @since 4.7.0
  15. *
  16. * @see WP_Customize_Setting
  17. */
  18. final class WP_Customize_Custom_CSS_Setting extends WP_Customize_Setting {
  19. /**
  20. * The setting type.
  21. *
  22. * @since 4.7.0
  23. * @access public
  24. * @var string
  25. */
  26. public $type = 'custom_css';
  27. /**
  28. * Setting Transport
  29. *
  30. * @since 4.7.0
  31. * @access public
  32. * @var string
  33. */
  34. public $transport = 'postMessage';
  35. /**
  36. * Capability required to edit this setting.
  37. *
  38. * @since 4.7.0
  39. * @access public
  40. * @var string
  41. */
  42. public $capability = 'edit_css';
  43. /**
  44. * Stylesheet
  45. *
  46. * @since 4.7.0
  47. * @access public
  48. * @var string
  49. */
  50. public $stylesheet = '';
  51. /**
  52. * WP_Customize_Custom_CSS_Setting constructor.
  53. *
  54. * @since 4.7.0
  55. * @access public
  56. *
  57. * @throws Exception If the setting ID does not match the pattern `custom_css[$stylesheet]`.
  58. *
  59. * @param WP_Customize_Manager $manager The Customize Manager class.
  60. * @param string $id An specific ID of the setting. Can be a
  61. * theme mod or option name.
  62. * @param array $args Setting arguments.
  63. */
  64. public function __construct( $manager, $id, $args = array() ) {
  65. parent::__construct( $manager, $id, $args );
  66. if ( 'custom_css' !== $this->id_data['base'] ) {
  67. throw new Exception( 'Expected custom_css id_base.' );
  68. }
  69. if ( 1 !== count( $this->id_data['keys'] ) || empty( $this->id_data['keys'][0] ) ) {
  70. throw new Exception( 'Expected single stylesheet key.' );
  71. }
  72. $this->stylesheet = $this->id_data['keys'][0];
  73. }
  74. /**
  75. * Add filter to preview post value.
  76. *
  77. * @since 4.7.9
  78. * @access public
  79. *
  80. * @return bool False when preview short-circuits due no change needing to be previewed.
  81. */
  82. public function preview() {
  83. if ( $this->is_previewed ) {
  84. return false;
  85. }
  86. $this->is_previewed = true;
  87. add_filter( 'wp_get_custom_css', array( $this, 'filter_previewed_wp_get_custom_css' ), 9, 2 );
  88. return true;
  89. }
  90. /**
  91. * Filter `wp_get_custom_css` for applying the customized value.
  92. *
  93. * This is used in the preview when `wp_get_custom_css()` is called for rendering the styles.
  94. *
  95. * @since 4.7.0
  96. * @access private
  97. * @see wp_get_custom_css()
  98. *
  99. * @param string $css Original CSS.
  100. * @param string $stylesheet Current stylesheet.
  101. * @return string CSS.
  102. */
  103. public function filter_previewed_wp_get_custom_css( $css, $stylesheet ) {
  104. if ( $stylesheet === $this->stylesheet ) {
  105. $customized_value = $this->post_value( null );
  106. if ( ! is_null( $customized_value ) ) {
  107. $css = $customized_value;
  108. }
  109. }
  110. return $css;
  111. }
  112. /**
  113. * Fetch the value of the setting. Will return the previewed value when `preview()` is called.
  114. *
  115. * @since 4.7.0
  116. * @access public
  117. * @see WP_Customize_Setting::value()
  118. *
  119. * @return string
  120. */
  121. public function value() {
  122. if ( $this->is_previewed ) {
  123. $post_value = $this->post_value( null );
  124. if ( null !== $post_value ) {
  125. return $post_value;
  126. }
  127. }
  128. $id_base = $this->id_data['base'];
  129. $value = '';
  130. $post = wp_get_custom_css_post( $this->stylesheet );
  131. if ( $post ) {
  132. $value = $post->post_content;
  133. }
  134. if ( empty( $value ) ) {
  135. $value = $this->default;
  136. }
  137. /** This filter is documented in wp-includes/class-wp-customize-setting.php */
  138. $value = apply_filters( "customize_value_{$id_base}", $value, $this );
  139. return $value;
  140. }
  141. /**
  142. * Validate CSS.
  143. *
  144. * Checks for imbalanced braces, brackets, and comments.
  145. * Notifications are rendered when the customizer state is saved.
  146. *
  147. * @todo There are cases where valid CSS can be incorrectly marked as invalid when strings or comments include balancing characters. To fix, CSS tokenization needs to be used.
  148. *
  149. * @since 4.7.0
  150. * @access public
  151. *
  152. * @param string $css The input string.
  153. * @return true|WP_Error True if the input was validated, otherwise WP_Error.
  154. */
  155. public function validate( $css ) {
  156. $validity = new WP_Error();
  157. if ( preg_match( '#</?\w+#', $css ) ) {
  158. $validity->add( 'illegal_markup', __( 'Markup is not allowed in CSS.' ) );
  159. }
  160. $imbalanced = false;
  161. // Make sure that there is a closing brace for each opening brace.
  162. if ( ! $this->validate_balanced_characters( '{', '}', $css ) ) {
  163. $validity->add( 'imbalanced_curly_brackets', sprintf(
  164. /* translators: 1: {}, 2: }, 3: { */
  165. __( 'Your curly brackets %1$s are imbalanced. Make sure there is a closing %2$s for every opening %3$s.' ),
  166. '<code>{}</code>',
  167. '<code>}</code>',
  168. '<code>{</code>'
  169. ) );
  170. $imbalanced = true;
  171. }
  172. // Ensure brackets are balanced.
  173. if ( ! $this->validate_balanced_characters( '[', ']', $css ) ) {
  174. $validity->add( 'imbalanced_braces', sprintf(
  175. /* translators: 1: [], 2: ], 3: [ */
  176. __( 'Your brackets %1$s are imbalanced. Make sure there is a closing %2$s for every opening %3$s.' ),
  177. '<code>[]</code>',
  178. '<code>]</code>',
  179. '<code>[</code>'
  180. ) );
  181. $imbalanced = true;
  182. }
  183. // Ensure parentheses are balanced.
  184. if ( ! $this->validate_balanced_characters( '(', ')', $css ) ) {
  185. $validity->add( 'imbalanced_parentheses', sprintf(
  186. /* translators: 1: (), 2: ), 3: ( */
  187. __( 'Your parentheses %1$s are imbalanced. Make sure there is a closing %2$s for every opening %3$s.' ),
  188. '<code>()</code>',
  189. '<code>)</code>',
  190. '<code>(</code>'
  191. ) );
  192. $imbalanced = true;
  193. }
  194. // Ensure double quotes are equal.
  195. if ( ! $this->validate_equal_characters( '"', $css ) ) {
  196. $validity->add( 'unequal_double_quotes', sprintf(
  197. /* translators: 1: " (double quote) */
  198. __( 'Your double quotes %1$s are uneven. Make sure there is a closing %1$s for every opening %1$s.' ),
  199. '<code>"</code>'
  200. ) );
  201. $imbalanced = true;
  202. }
  203. /*
  204. * Make sure any code comments are closed properly.
  205. *
  206. * The first check could miss stray an unpaired comment closing figure, so if
  207. * The number appears to be balanced, then check for equal numbers
  208. * of opening/closing comment figures.
  209. *
  210. * Although it may initially appear redundant, we use the first method
  211. * to give more specific feedback to the user.
  212. */
  213. $unclosed_comment_count = $this->validate_count_unclosed_comments( $css );
  214. if ( 0 < $unclosed_comment_count ) {
  215. $validity->add( 'unclosed_comment', sprintf(
  216. /* translators: 1: number of unclosed comments, 2: *​/ */
  217. _n(
  218. 'There is %1$s unclosed code comment. Close each comment with %2$s.',
  219. 'There are %1$s unclosed code comments. Close each comment with %2$s.',
  220. $unclosed_comment_count
  221. ),
  222. $unclosed_comment_count,
  223. '<code>*/</code>'
  224. ) );
  225. $imbalanced = true;
  226. } elseif ( ! $this->validate_balanced_characters( '/*', '*/', $css ) ) {
  227. $validity->add( 'imbalanced_comments', sprintf(
  228. /* translators: 1: *​/, 2: /​* */
  229. __( 'There is an extra %1$s, indicating an end to a comment. Be sure that there is an opening %2$s for every closing %1$s.' ),
  230. '<code>*/</code>',
  231. '<code>/*</code>'
  232. ) );
  233. $imbalanced = true;
  234. }
  235. if ( $imbalanced && $this->is_possible_content_error( $css ) ) {
  236. $validity->add( 'possible_false_positive', sprintf(
  237. /* translators: %s: content: ""; */
  238. __( 'Imbalanced/unclosed character errors can be caused by %s declarations. You may need to remove this or add it to a custom CSS file.' ),
  239. '<code>content: "";</code>'
  240. ) );
  241. }
  242. if ( empty( $validity->errors ) ) {
  243. $validity = parent::validate( $css );
  244. }
  245. return $validity;
  246. }
  247. /**
  248. * Store the CSS setting value in the custom_css custom post type for the stylesheet.
  249. *
  250. * @since 4.7.0
  251. * @access public
  252. *
  253. * @param string $css The input value.
  254. * @return int|false The post ID or false if the value could not be saved.
  255. */
  256. public function update( $css ) {
  257. if ( empty( $css ) ) {
  258. $css = '';
  259. }
  260. $r = wp_update_custom_css_post( $css, array(
  261. 'stylesheet' => $this->stylesheet,
  262. ) );
  263. if ( $r instanceof WP_Error ) {
  264. return false;
  265. }
  266. $post_id = $r->ID;
  267. // Cache post ID in theme mod for performance to avoid additional DB query.
  268. if ( $this->manager->get_stylesheet() === $this->stylesheet ) {
  269. set_theme_mod( 'custom_css_post_id', $post_id );
  270. }
  271. return $post_id;
  272. }
  273. /**
  274. * Ensure there are a balanced number of paired characters.
  275. *
  276. * This is used to check that the number of opening and closing
  277. * characters is equal.
  278. *
  279. * For instance, there should be an equal number of braces ("{", "}")
  280. * in the CSS.
  281. *
  282. * @since 4.7.0
  283. * @access private
  284. *
  285. * @param string $opening_char The opening character.
  286. * @param string $closing_char The closing character.
  287. * @param string $css The CSS input string.
  288. *
  289. * @return bool
  290. */
  291. private function validate_balanced_characters( $opening_char, $closing_char, $css ) {
  292. return substr_count( $css, $opening_char ) === substr_count( $css, $closing_char );
  293. }
  294. /**
  295. * Ensure there are an even number of paired characters.
  296. *
  297. * This is used to check that the number of a specific
  298. * character is even.
  299. *
  300. * For instance, there should be an even number of double quotes
  301. * in the CSS.
  302. *
  303. * @since 4.7.0
  304. * @access private
  305. *
  306. * @param string $char A character.
  307. * @param string $css The CSS input string.
  308. * @return bool Equality.
  309. */
  310. private function validate_equal_characters( $char, $css ) {
  311. $char_count = substr_count( $css, $char );
  312. return ( 0 === $char_count % 2 );
  313. }
  314. /**
  315. * Count unclosed CSS Comments.
  316. *
  317. * Used during validation.
  318. *
  319. * @see self::validate()
  320. *
  321. * @since 4.7.0
  322. * @access private
  323. *
  324. * @param string $css The CSS input string.
  325. * @return int Count.
  326. */
  327. private function validate_count_unclosed_comments( $css ) {
  328. $count = 0;
  329. $comments = explode( '/*', $css );
  330. if ( ! is_array( $comments ) || ( 1 >= count( $comments ) ) ) {
  331. return $count;
  332. }
  333. unset( $comments[0] ); // The first item is before the first comment.
  334. foreach ( $comments as $comment ) {
  335. if ( false === strpos( $comment, '*/' ) ) {
  336. $count++;
  337. }
  338. }
  339. return $count;
  340. }
  341. /**
  342. * Find "content:" within a string.
  343. *
  344. * Imbalanced/Unclosed validation errors may be caused
  345. * when a character is used in a "content:" declaration.
  346. *
  347. * This function is used to detect if this is a possible
  348. * cause of the validation error, so that if it is,
  349. * a notification may be added to the Validation Errors.
  350. *
  351. * Example:
  352. * .element::before {
  353. * content: "(\"";
  354. * }
  355. * .element::after {
  356. * content: "\")";
  357. * }
  358. *
  359. * Using ! empty() because strpos() may return non-boolean values
  360. * that evaluate to false. This would be problematic when
  361. * using a strict "false === strpos()" comparison.
  362. *
  363. * @since 4.7.0
  364. * @access private
  365. *
  366. * @param string $css The CSS input string.
  367. * @return bool
  368. */
  369. private function is_possible_content_error( $css ) {
  370. $found = preg_match( '/\bcontent\s*:/', $css );
  371. if ( ! empty( $found ) ) {
  372. return true;
  373. }
  374. return false;
  375. }
  376. }