PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 15.9-a.7
Jetpack – WP Security, Backup, Speed, & Growth v15.9-a.7
15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / modules / markdown / easy-markdown.php
jetpack / modules / markdown Last commit date
easy-markdown.php 1 week ago
easy-markdown.php
900 lines
1 <?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Plugin URI: https://automattic.com/
4 * Plugin Name: Easy Markdown
5 * Description: Write in Markdown, publish in WordPress
6 * Version: 0.1
7 * Author: Matt Wiebe
8 * Author URI: https://automattic.com/
9 * Text Domain: jetpack
10 *
11 * @package automattic/jetpack
12 */
13
14 /**
15 * Copyright (c) Automattic. All rights reserved.
16 *
17 * Released under the GPL license
18 * https://www.opensource.org/licenses/gpl-license.php
19 *
20 * This is an add-on for WordPress
21 * https://wordpress.org/
22 *
23 * **********************************************************************
24 * This program is free software; you can redistribute it and/or modify
25 * it under the terms of the GNU General Public License as published by
26 * the Free Software Foundation; either version 2 of the License, or
27 * (at your option) any later version.
28 *
29 * This program is distributed in the hope that it will be useful,
30 * but WITHOUT ANY WARRANTY; without even the implied warranty of
31 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
32 * GNU General Public License for more details.
33 * **********************************************************************
34 */
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit( 0 );
38 }
39
40 /**
41 * WPCom_Markdown class.
42 */
43 class WPCom_Markdown {
44
45 const POST_OPTION = 'wpcom_publish_posts_with_markdown';
46 const COMMENT_OPTION = 'wpcom_publish_comments_with_markdown';
47 const POST_TYPE_SUPPORT = 'wpcom-markdown';
48 const IS_MD_META = '_wpcom_is_markdown';
49
50 /**
51 * Our markdown parser.
52 *
53 * @var WPCom_GHF_Markdown_Parser
54 */
55 private static $parser;
56
57 /**
58 * An instance of the markdown class.
59 *
60 * @var WPCom_Markdown
61 */
62 private static $instance;
63
64 /**
65 * To ensure that our munged posts over xml-rpc are removed from the cache.
66 *
67 * @var array
68 */
69 public $posts_to_uncache = array();
70
71 /**
72 * Posts and parents to monitor.
73 *
74 * @var array
75 */
76 private $monitoring = array(
77 'post' => array(),
78 'parent' => array(),
79 );
80
81 /**
82 * Whether or not kses filters were removed. Only set if removal was attempted.
83 *
84 * @var ?bool
85 */
86 public $kses;
87
88 /**
89 * Yay singletons!
90 *
91 * @return object WPCom_Markdown instance
92 */
93 public static function get_instance() {
94 if ( ! self::$instance ) {
95 self::$instance = new self();
96 }
97 return self::$instance;
98 }
99
100 /**
101 * Kicks things off on `init` action
102 */
103 public function load() {
104 $this->add_default_post_type_support();
105 $this->maybe_load_actions_and_filters();
106 if ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) {
107 // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
108 add_action( 'switch_blog', array( $this, 'maybe_load_actions_and_filters' ), 10, 2 );
109 }
110 add_action( 'admin_init', array( $this, 'register_setting' ) );
111 add_action( 'admin_init', array( $this, 'maybe_unload_for_bulk_edit' ) );
112 if ( current_theme_supports( 'o2' ) || class_exists( 'P2' ) ) {
113 $this->add_o2_helpers();
114 }
115 }
116
117 /**
118 * If we're in a bulk edit session, unload so that we don't lose our markdown metadata
119 */
120 public function maybe_unload_for_bulk_edit() {
121 if ( isset( $_REQUEST['bulk_edit'] ) && $this->is_posting_enabled() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
122 $this->unload_markdown_for_posts();
123 }
124 }
125
126 /**
127 * Called on init and fires on switch_blog to decide if our actions and filters
128 * should be running.
129 *
130 * @param int|null $new_blog_id New blog ID.
131 * @param int|null $old_blog_id Old blog ID.
132 * @return null
133 */
134 public function maybe_load_actions_and_filters( $new_blog_id = null, $old_blog_id = null ) {
135
136 // When WP sites are being installed, the options table is not available yet.
137 if ( function_exists( 'wp_installing' ) && wp_installing() ) {
138 return;
139 }
140
141 // If this is a switch_to_blog call, and the blog isn't changing, we'll already be loaded.
142 if ( $new_blog_id && $new_blog_id === $old_blog_id ) {
143 return;
144 }
145
146 if ( $this->is_posting_enabled() ) {
147 $this->load_markdown_for_posts();
148 } else {
149 $this->unload_markdown_for_posts();
150 }
151
152 if ( $this->is_commenting_enabled() ) {
153 $this->load_markdown_for_comments();
154 } else {
155 $this->unload_markdown_for_comments();
156 }
157 }
158
159 /**
160 * Set up hooks for enabling Markdown conversion on posts
161 */
162 public function load_markdown_for_posts() {
163 add_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ), 10, 2 );
164 add_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
165 add_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
166 add_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10, 2 );
167 add_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10, 2 );
168 add_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10, 2 );
169 add_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10, 2 );
170 add_filter( '_wp_post_revision_fields', array( $this, 'wp_post_revision_fields' ) );
171 add_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
172 add_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
173 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
174 $this->check_for_early_methods();
175 }
176 }
177
178 /**
179 * Removes hooks to disable Markdown conversion on posts
180 */
181 public function unload_markdown_for_posts() {
182 remove_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ) );
183 remove_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
184 remove_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
185 remove_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10 );
186 remove_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10 );
187 remove_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10 );
188 remove_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10 );
189 remove_filter( '_wp_post_revision_fields', array( $this, 'wp_post_revision_fields' ) );
190 remove_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
191 remove_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
192 }
193
194 /**
195 * Set up hooks for enabling Markdown conversion on comments
196 */
197 protected function load_markdown_for_comments() {
198 // Use priority 9 so that Markdown runs before KSES, which can clean up
199 // any munged HTML.
200 add_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
201 }
202
203 /**
204 * Removes hooks to disable Markdown conversion
205 */
206 protected function unload_markdown_for_comments() {
207 remove_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
208 }
209
210 /**
211 * The o2 plugin does some of what we do. Let's take precedence.
212 */
213 public function add_o2_helpers() {
214 if ( $this->is_posting_enabled() ) {
215 add_filter( 'content_save_pre', array( $this, 'o2_escape_lists' ), 1 );
216 }
217
218 add_filter( 'o2_preview_post', array( $this, 'o2_preview_post' ) );
219 add_filter( 'o2_preview_comment', array( $this, 'o2_preview_comment' ) );
220
221 add_filter( 'wpcom_markdown_transform_pre', array( $this, 'o2_unescape_lists' ) );
222 add_filter( 'wpcom_untransformed_content', array( $this, 'o2_unescape_lists' ) );
223 }
224
225 /**
226 * If Markdown is enabled for posts on this blog, filter the text for o2 previews
227 *
228 * @param string $text Post text.
229 * @return string Post text transformed through the magic of Markdown
230 */
231 public function o2_preview_post( $text ) {
232 if ( $this->is_posting_enabled() ) {
233 $text = $this->transform( $text, array( 'unslash' => false ) );
234 }
235 return $text;
236 }
237
238 /**
239 * If Markdown is enabled for comments on this blog, filter the text for o2 previews
240 *
241 * @param string $text Comment text.
242 * @return string Comment text transformed through the magic of Markdown
243 */
244 public function o2_preview_comment( $text ) {
245 if ( $this->is_commenting_enabled() ) {
246 $text = $this->transform( $text, array( 'unslash' => false ) );
247 }
248 return $text;
249 }
250
251 /**
252 * Escapes lists so that o2 doesn't trounce them
253 *
254 * @param string $text Post/comment text.
255 * @return string Text escaped with HTML entity for asterisk.
256 */
257 public function o2_escape_lists( $text ) {
258 return preg_replace( '/^\\* /um', '&#42; ', $text );
259 }
260
261 /**
262 * Unescapes the token we inserted on o2_escape_lists
263 *
264 * @param string $text Post/comment text with HTML entities for asterisks.
265 * @return string Text with the HTML entity removed
266 */
267 public function o2_unescape_lists( $text ) {
268 return preg_replace( '/^[&]\#042; /um', '* ', $text );
269 }
270
271 /**
272 * Preserve code blocks from being munged by KSES before they have a chance
273 *
274 * @param string $text post content.
275 * @return string post content with code blocks escaped.
276 */
277 public function preserve_code_blocks( $text ) {
278 return $this->get_parser()->codeblock_preserve( $text );
279 }
280
281 /**
282 * Remove KSES if it's there. Store the result to manually invoke later if needed.
283 */
284 public function maybe_remove_kses() {
285 // Filters return true if they existed before you removed them.
286 if ( $this->is_posting_enabled() ) {
287 $this->kses = remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' ) && remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
288 }
289 }
290
291 /**
292 * Add our Writing and Discussion settings.
293 */
294 public function register_setting() {
295 add_settings_field( self::POST_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'post_field' ), 'writing' );
296 register_setting( 'writing', self::POST_OPTION, array( $this, 'sanitize_setting' ) );
297 add_settings_field( self::COMMENT_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'comment_field' ), 'discussion' );
298 register_setting( 'discussion', self::COMMENT_OPTION, array( $this, 'sanitize_setting' ) );
299 }
300
301 /**
302 * Sanitize setting. Don't really want to store "on" value, so we'll store "1" instead!
303 *
304 * @param string $input Value received by settings API via $_POST.
305 * @return bool Cast to boolean.
306 */
307 public function sanitize_setting( $input ) {
308 return (bool) $input;
309 }
310
311 /**
312 * Prints HTML for the Writing setting
313 */
314 public function post_field() {
315 printf(
316 '<label><input name="%1$s" id="%1$s" type="checkbox"%2$s /> %3$s</label><p class="description">%4$s</p>',
317 esc_attr( self::POST_OPTION ),
318 checked( $this->is_posting_enabled(), true, false ),
319 esc_html__( 'Use Markdown for posts and pages.', 'jetpack' ),
320 sprintf( '<a href="%s" data-target="wpcom-help-center">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
321 );
322 }
323
324 /**
325 * Prints HTML for the Discussion setting
326 */
327 public function comment_field() {
328 printf(
329 '<label><input name="%1$s" id="%1$s" type="checkbox"%2$s /> %3$s</label><p class="description">%4$s</p>',
330 esc_attr( self::COMMENT_OPTION ),
331 checked( $this->is_commenting_enabled(), true, false ),
332 esc_html__( 'Use Markdown for comments.', 'jetpack' ),
333 sprintf( '<a href="%s" data-target="wpcom-help-center">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
334 );
335 }
336
337 /**
338 * Get the support url for Markdown
339 *
340 * @uses apply_filters
341 * @return string support url
342 */
343 protected function get_support_url() {
344 /**
345 * Filter the Markdown support URL.
346 *
347 * @module markdown
348 *
349 * @since 2.8.0
350 *
351 * @param string $url Markdown support URL.
352 */
353 return apply_filters( 'easy_markdown_support_url', 'https://en.support.wordpress.com/markdown-quick-reference/' );
354 }
355
356 /**
357 * Is Mardown conversion for posts enabled?
358 *
359 * @return boolean
360 */
361 public function is_posting_enabled() {
362 return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::POST_OPTION, '' );
363 }
364
365 /**
366 * Is Markdown conversion for comments enabled?
367 *
368 * @return boolean
369 */
370 public function is_commenting_enabled() {
371 return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::COMMENT_OPTION, '' );
372 }
373
374 /**
375 * Check if a $post_id has Markdown enabled
376 *
377 * @param int $post_id A post ID.
378 * @return boolean
379 */
380 public function is_markdown( $post_id ) {
381 return get_metadata( 'post', $post_id, self::IS_MD_META, true );
382 }
383
384 /**
385 * Set Markdown as enabled on a post_id. We skip over update_postmeta so we
386 * can sneakily set metadata on post revisions, which we need.
387 *
388 * @param int $post_id A post ID.
389 * @return bool The metadata was successfully set.
390 */
391 protected function set_as_markdown( $post_id ) {
392 return update_metadata( 'post', $post_id, self::IS_MD_META, true );
393 }
394
395 /**
396 * Get our Markdown parser object, optionally requiring all of our needed classes and
397 * instantiating our parser.
398 *
399 * @return object WPCom_GHF_Markdown_Parser instance.
400 */
401 public function get_parser() {
402
403 if ( ! self::$parser ) {
404 require_once JETPACK__PLUGIN_DIR . '/_inc/lib/markdown.php';
405 self::$parser = new WPCom_GHF_Markdown_Parser();
406 }
407
408 return self::$parser;
409 }
410
411 /**
412 * We don't want Markdown conversion all over the place.
413 */
414 public function add_default_post_type_support() {
415 add_post_type_support( 'post', self::POST_TYPE_SUPPORT );
416 add_post_type_support( 'page', self::POST_TYPE_SUPPORT );
417 add_post_type_support( 'revision', self::POST_TYPE_SUPPORT );
418 }
419
420 /**
421 * Figure out the post type of the post screen we're on
422 *
423 * @deprecated since 10.8
424 * @return string Current post_type
425 */
426 protected function get_post_screen_post_type() {
427 _deprecated_function( __METHOD__, 'jetpack-10.8', '' );
428
429 global $pagenow;
430 $post_type = filter_input( INPUT_GET, 'post_type', FILTER_UNSAFE_RAW );
431 $post_id = filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT );
432
433 if ( 'post-new.php' === $pagenow ) {
434 return ! empty( $post_type ) ? $post_type : 'post';
435 }
436
437 if ( $post_id ) {
438 $post_type = get_post_type( $post_id );
439 }
440
441 return ! empty( $post_type ) ? $post_type : 'post';
442 }
443
444 /**
445 * Swap post_content and post_content_filtered for editing
446 *
447 * @param string $content Post content.
448 * @param int $id post ID.
449 * @return string Swapped content
450 */
451 public function edit_post_content( $content, $id ) {
452 if ( $this->is_markdown( $id ) ) {
453 $post = get_post( $id );
454 if ( $post && ! empty( $post->post_content_filtered ) ) {
455 $post = $this->swap_for_editing( $post );
456 return $post->post_content;
457 }
458 }
459 return $content;
460 }
461
462 /**
463 * Swap post_content_filtered and post_content for editing
464 *
465 * @param string $content Post content_filtered.
466 * @param int $id post ID.
467 * @return string Swapped content
468 */
469 public function edit_post_content_filtered( $content, $id ) {
470 // if markdown was disabled, let's turn this off.
471 if ( ! $this->is_posting_enabled() && $this->is_markdown( $id ) ) {
472 $post = get_post( $id );
473 if ( $post && ! empty( $post->post_content_filtered ) ) {
474 $content = '';
475 }
476 }
477 return $content;
478 }
479
480 /**
481 * Some tags are allowed to have a 'markdown' attribute, allowing them to contain Markdown.
482 * We need to tell KSES about those tags.
483 *
484 * @param array $tags List of tags that KSES allows.
485 * @param string $context The context that KSES is allowing these tags.
486 * @return array The tags that KSES allows, with our extra 'markdown' parameter where necessary.
487 */
488 public function wp_kses_allowed_html( $tags, $context ) {
489 if ( 'post' !== $context ) {
490 return $tags;
491 }
492
493 $re = '/' . $this->get_parser()->contain_span_tags_re . '/';
494 foreach ( $tags as $tag => $attributes ) {
495
496 // In case other filters have changed the value to a non-array, we skip it.
497 if ( ! is_array( $attributes ) ) {
498 continue;
499 }
500
501 if ( preg_match( $re, $tag ) ) {
502 $attributes['markdown'] = true;
503 $tags[ $tag ] = $attributes;
504 }
505 }
506
507 return $tags;
508 }
509
510 /**
511 * TinyMCE needs to know not to strip the 'markdown' attribute. Unfortunately, it doesn't
512 * really offer a nice API for allowed attributes, so we have to manually add it
513 * to the schema instead.
514 */
515 public function after_wp_tiny_mce() {
516 ?>
517 <script type="text/javascript">
518 jQuery( function() {
519 ( 'undefined' !== typeof tinymce ) && tinymce.on( 'AddEditor', function( event ) {
520 event.editor.on( 'BeforeSetContent', function( event ) {
521 var editor = event.target;
522 Object.keys( editor.schema.elements ).forEach( function( key, index ) {
523 editor.schema.elements[ key ].attributes['markdown'] = {};
524 editor.schema.elements[ key ].attributesOrder.push( 'markdown' );
525 } );
526 } );
527 }, true );
528 } );
529 </script>
530 <?php
531 }
532
533 /**
534 * Magic happens here. Markdown is converted and stored on post_content. Original Markdown is stored
535 * in post_content_filtered so that we can continue editing as Markdown.
536 *
537 * @param array $post_data The post data that will be inserted into the DB. Slashed.
538 * @param array $postarr All the stuff that was in $_POST.
539 * @return array $post_data with post_content and post_content_filtered modified
540 */
541 public function wp_insert_post_data( $post_data, $postarr ) {
542 // $post_data array is slashed!
543 $post_id = $postarr['ID'] ?? false;
544 // bail early if markdown is disabled or this post type is unsupported.
545 if ( ! $this->is_posting_enabled() || ! post_type_supports( $post_data['post_type'], self::POST_TYPE_SUPPORT ) ) {
546 // it's disabled, but maybe this *was* a markdown post before.
547 if ( $this->is_markdown( $post_id ) && ! empty( $post_data['post_content_filtered'] ) ) {
548 $post_data['post_content_filtered'] = '';
549 }
550 // we have no context to determine supported post types in the `post_content_pre` hook,
551 // which already ran to sanitize code blocks. Undo that.
552 $post_data['post_content'] = $this->get_parser()->codeblock_restore( $post_data['post_content'] );
553 return $post_data;
554 }
555 // rejigger post_content and post_content_filtered
556 // revisions are already in the right place, except when we're restoring, but that's taken care of elsewhere
557 // also prevent quick edit feature from overriding already-saved markdown (issue https://github.com/Automattic/jetpack/issues/636).
558 if ( 'revision' !== $post_data['post_type'] && ! isset( $_POST['_inline_edit'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
559 /**
560 * Filter the original post content passed to Markdown.
561 *
562 * @module markdown
563 *
564 * @since 2.8.0
565 *
566 * @param string $post_data['post_content'] Untransformed post content.
567 */
568 $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
569 $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_id ) );
570 /** This filter is already documented in core/wp-includes/default-filters.php */
571 $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
572 } elseif ( str_starts_with( $post_data['post_name'], $post_data['post_parent'] . '-autosave' ) ) {
573 // autosaves for previews are weird.
574 /** This filter is already documented in modules/markdown/easy-markdown.php */
575 $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
576 $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_data['post_parent'] ) );
577 /** This filter is already documented in core/wp-includes/default-filters.php */
578 $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
579 }
580
581 // set as markdown on the wp_insert_post hook later.
582 if ( $post_id ) {
583 $this->monitoring['post'][ $post_id ] = true;
584 } else {
585 $this->monitoring['content'] = wp_unslash( $post_data['post_content'] );
586 }
587 if ( 'revision' === $postarr['post_type'] && $this->is_markdown( $postarr['post_parent'] ) ) {
588 $this->monitoring['parent'][ $postarr['post_parent'] ] = true;
589 }
590
591 return $post_data;
592 }
593
594 /**
595 * Calls on wp_insert_post action, after wp_insert_post_data. This way we can
596 * still set postmeta on our revisions after it's all been deleted.
597 *
598 * @param int $post_id The post ID that has just been added/updated.
599 */
600 public function wp_insert_post( $post_id ) {
601 $post_parent = get_post_field( 'post_parent', $post_id );
602 // this didn't have an ID yet. Compare the content that was just saved.
603 if ( isset( $this->monitoring['content'] ) && get_post_field( 'post_content', $post_id ) === $this->monitoring['content'] ) {
604 unset( $this->monitoring['content'] );
605 $this->set_as_markdown( $post_id );
606 }
607 if ( isset( $this->monitoring['post'][ $post_id ] ) ) {
608 unset( $this->monitoring['post'][ $post_id ] );
609 $this->set_as_markdown( $post_id );
610 } elseif ( isset( $this->monitoring['parent'][ $post_parent ] ) ) {
611 unset( $this->monitoring['parent'][ $post_parent ] );
612 $this->set_as_markdown( $post_id );
613 }
614 }
615
616 /**
617 * Run a comment through Markdown. Easy peasy.
618 *
619 * @param string $content - the content.
620 * @return string
621 */
622 public function pre_comment_content( $content ) {
623 return $this->transform(
624 $content,
625 array(
626 'id' => $this->comment_hash( $content ),
627 )
628 );
629 }
630
631 /**
632 * Return a comment hash.
633 *
634 * @param string $content - the content of the comment.
635 */
636 protected function comment_hash( $content ) {
637 return 'c-' . substr( md5( $content ), 0, 8 );
638 }
639
640 /**
641 * Markdown conversion. Some DRYness for repetitive tasks.
642 *
643 * @param string $text Content to be run through Markdown.
644 * @param array $args Arguments, with keys:
645 * id: provide a string to prefix footnotes with a unique identifier
646 * unslash: when true, expects and returns slashed data
647 * decode_code_blocks: when true, assume that text in fenced code blocks is already
648 * HTML encoded and should be decoded before being passed to Markdown, which does
649 * its own encoding.
650 * @return string Markdown-processed content
651 */
652 public function transform( $text, $args = array() ) {
653 // If this contains Gutenberg content, let's keep it intact.
654 if ( has_blocks( $text ) ) {
655 return $text;
656 }
657
658 $args = wp_parse_args(
659 $args,
660 array(
661 'id' => false,
662 'unslash' => true,
663 'decode_code_blocks' => ! $this->get_parser()->use_code_shortcode,
664 )
665 );
666 // probably need to unslash.
667 if ( $args['unslash'] ) {
668 $text = wp_unslash( $text );
669 }
670
671 /**
672 * Filter the content to be run through Markdown, before it's transformed by Markdown.
673 *
674 * @module markdown
675 *
676 * @since 2.8.0
677 *
678 * @param string $text Content to be run through Markdown
679 * @param array $args Array of Markdown options.
680 */
681 $text = apply_filters( 'wpcom_markdown_transform_pre', $text, $args ) ?? '';
682 // ensure our paragraphs are separated.
683 $text = str_replace( array( '</p><p>', "</p>\n<p>" ), "</p>\n\n<p>", $text );
684 // visual editor likes to add <p>s. Buh-bye.
685 $text = $this->get_parser()->unp( $text );
686 // sometimes we get an encoded > at start of line, breaking blockquotes.
687 $text = preg_replace( '/^&gt;/m', '>', $text );
688 // prefixes are because we need to namespace footnotes by post_id.
689 $this->get_parser()->fn_id_prefix = $args['id'] ? $args['id'] . '-' : '';
690 // If we're not using the code shortcode, prevent over-encoding.
691 if ( $args['decode_code_blocks'] ) {
692 $text = $this->get_parser()->codeblock_restore( $text );
693 }
694 // Transform it!
695 $text = $this->get_parser()->transform( $text );
696 // Fix footnotes - kses doesn't like the : IDs it supplies.
697 $text = preg_replace( '/((id|href)="#?fn(ref)?):/', '$1-', $text );
698 // Markdown inserts extra spaces to make itself work. Buh-bye.
699 $text = rtrim( $text );
700 /**
701 * Filter the content to be run through Markdown, after it was transformed by Markdown.
702 *
703 * @module markdown
704 *
705 * @since 2.8.0
706 *
707 * @param string $text Content to be run through Markdown
708 * @param array $args Array of Markdown options.
709 */
710 $text = apply_filters( 'wpcom_markdown_transform_post', $text, $args );
711
712 // probably need to re-slash.
713 if ( $args['unslash'] ) {
714 $text = wp_slash( $text );
715 }
716
717 return $text;
718 }
719
720 /**
721 * Shows Markdown in the Revisions screen, and ensures that post_content_filtered
722 * is maintained on revisions
723 *
724 * @param array $fields Post fields pertinent to revisions.
725 */
726 public function wp_post_revision_fields( $fields ) {
727 $fields['post_content_filtered'] = __( 'Markdown content', 'jetpack' );
728 return $fields;
729 }
730
731 /**
732 * Do some song and dance to keep all post_content and post_content_filtered content
733 * in the expected place when a post revision is restored.
734 *
735 * @param int $post_id The post ID have a restore done to it.
736 * @param int $revision_id The revision ID being restored.
737 */
738 public function wp_restore_post_revision( $post_id, $revision_id ) {
739 if ( $this->is_markdown( $revision_id ) ) {
740 $revision = get_post( $revision_id, ARRAY_A );
741 $post = get_post( $post_id, ARRAY_A );
742 $post['post_content'] = $revision['post_content_filtered']; // Yes, we put it in post_content, because our wp_insert_post_data() expects that.
743 // set this flag so we can restore the post_content_filtered on the last revision later.
744 $this->monitoring['restore'] = true;
745 // let's not make a revision of our fixing update.
746 add_filter( 'wp_revisions_to_keep', '__return_false', 99 );
747 wp_update_post( $post );
748 $this->fix_latest_revision_on_restore( $post_id );
749 remove_filter( 'wp_revisions_to_keep', '__return_false', 99 );
750 }
751 }
752
753 /**
754 * We need to ensure the last revision has Markdown, not HTML in its post_content_filtered
755 * column after a restore.
756 *
757 * @param int $post_id The post ID that was just restored.
758 */
759 protected function fix_latest_revision_on_restore( $post_id ) {
760 global $wpdb;
761 $post = get_post( $post_id );
762 $last_revision = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_type = 'revision' AND post_parent = %d ORDER BY ID DESC", $post->ID ) );
763 $last_revision->post_content_filtered = $post->post_content_filtered;
764 wp_insert_post( (array) $last_revision );
765 }
766
767 /**
768 * Kicks off magic for an XML-RPC session. We want to keep editing Markdown
769 * and publishing HTML.
770 *
771 * @param string $xmlrpc_method The current XML-RPC method.
772 */
773 public function xmlrpc_actions( $xmlrpc_method ) {
774 switch ( $xmlrpc_method ) {
775 case 'metaWeblog.getRecentPosts':
776 case 'wp.getPosts':
777 case 'wp.getPages':
778 add_action( 'parse_query', array( $this, 'make_filterable' ), 10, 1 );
779 break;
780 case 'wp.getPost':
781 $this->prime_post_cache();
782 break;
783 }
784 }
785
786 /**
787 * Function metaWeblog.getPost and wp.getPage fire xmlrpc_call action *after* get_post() is called.
788 * So, we have to detect those methods and prime the post cache early.
789 *
790 * @return null
791 */
792 protected function check_for_early_methods() {
793 $raw_post_data = file_get_contents( 'php://input' );
794 if ( ! str_contains( $raw_post_data, 'metaWeblog.getPost' )
795 && ! str_contains( $raw_post_data, 'wp.getPage' ) ) {
796 return;
797 }
798 include_once ABSPATH . WPINC . '/class-IXR.php';
799 $message = new IXR_Message( $raw_post_data );
800 $message->parse();
801 $post_id_position = 'metaWeblog.getPost' === $message->methodName ? 0 : 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
802 $this->prime_post_cache( $message->params[ $post_id_position ] ?? false );
803 }
804
805 /**
806 * Prime the post cache with swapped post_content. This is a sneaky way of getting around
807 * the fact that there are no good hooks to call on the *.getPost xmlrpc methods.
808 *
809 * @param bool $post_id - the post ID that we're priming.
810 */
811 private function prime_post_cache( $post_id = false ) {
812 global $wp_xmlrpc_server;
813 if ( ! $post_id ) {
814 if ( isset( $wp_xmlrpc_server->message->params[3] ) ) {
815 $post_id = $wp_xmlrpc_server->message->params[3];
816 } else {
817 return; // Exit early if we can't get a valid post_id
818 }
819 }
820
821 // prime the post cache.
822 if ( $this->is_markdown( $post_id ) ) {
823 $post = get_post( $post_id );
824 if ( ! empty( $post->post_content_filtered ) ) {
825 wp_cache_delete( $post->ID, 'posts' );
826 $post = $this->swap_for_editing( $post );
827 wp_cache_add( $post->ID, $post, 'posts' );
828 $this->posts_to_uncache[] = $post_id;
829 }
830 }
831 // uncache munged posts if using a persistent object cache.
832 if ( wp_using_ext_object_cache() ) {
833 add_action( 'shutdown', array( $this, 'uncache_munged_posts' ) );
834 }
835 }
836
837 /**
838 * Swaps `post_content_filtered` back to `post_content` for editing purposes.
839 *
840 * @param object $post WP_Post object.
841 * @return object WP_Post object with swapped `post_content_filtered` and `post_content`.
842 */
843 protected function swap_for_editing( $post ) {
844 $markdown = $post->post_content_filtered;
845 // unencode encoded code blocks.
846 $markdown = $this->get_parser()->codeblock_restore( $markdown );
847 // restore beginning of line blockquotes.
848 $markdown = preg_replace( '/^&gt; /m', '> ', $markdown );
849 $post->post_content_filtered = $post->post_content;
850 $post->post_content = $markdown;
851 return $post;
852 }
853
854 /**
855 * We munge the post cache to serve proper markdown content to XML-RPC clients.
856 * Uncache these after the XML-RPC session ends.
857 */
858 public function uncache_munged_posts() {
859 // $this context gets lost in testing sometimes. Weird.
860 foreach ( self::get_instance()->posts_to_uncache as $post_id ) {
861 wp_cache_delete( $post_id, 'posts' );
862 }
863 }
864
865 /**
866 * Since *.(get)?[Rr]ecentPosts calls get_posts with suppress filters on, we need to
867 * turn them back on so that we can swap things for editing.
868 *
869 * @param object $wp_query WP_Query object.
870 */
871 public function make_filterable( $wp_query ) {
872 $wp_query->set( 'suppress_filters', false );
873 add_action( 'the_posts', array( $this, 'the_posts' ), 10, 2 );
874 }
875
876 /**
877 * Swaps post_content and post_content_filtered for editing.
878 *
879 * @param array $posts Posts returned by the just-completed query.
880 * @return array Modified $posts
881 */
882 public function the_posts( $posts ) {
883 foreach ( $posts as $key => $post ) {
884 if ( $this->is_markdown( $post->ID ) && ! empty( $posts[ $key ]->post_content_filtered ) ) {
885 $markdown = $posts[ $key ]->post_content_filtered;
886 $posts[ $key ]->post_content_filtered = $posts[ $key ]->post_content;
887 $posts[ $key ]->post_content = $markdown;
888 }
889 }
890 return $posts;
891 }
892
893 /**
894 * Singleton silence is golden
895 */
896 private function __construct() {}
897 }
898
899 add_action( 'init', array( WPCom_Markdown::get_instance(), 'load' ) );
900