Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 258
0.00% covered (danger)
0.00%
0 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCom_Markdown
0.00% covered (danger)
0.00%
0 / 255
0.00% covered (danger)
0.00%
0 / 46
14520
0.00% covered (danger)
0.00%
0 / 1
 get_instance
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 load
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 maybe_unload_for_bulk_edit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 maybe_load_actions_and_filters
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 load_markdown_for_posts
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 unload_markdown_for_posts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 load_markdown_for_comments
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unload_markdown_for_comments
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_o2_helpers
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 o2_preview_post
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 o2_preview_comment
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 o2_escape_lists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 o2_unescape_lists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preserve_code_blocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_remove_kses
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 register_setting
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 sanitize_setting
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 post_field
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 comment_field
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 get_support_url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_posting_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_commenting_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_markdown
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_as_markdown
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_parser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 add_default_post_type_support
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_post_screen_post_type
n/a
0 / 0
n/a
0 / 0
5
 edit_post_content
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 edit_post_content_filtered
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 wp_kses_allowed_html
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 after_wp_tiny_mce
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 wp_insert_post_data
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
156
 wp_insert_post
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 pre_comment_content
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 comment_hash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transform
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 wp_post_revision_fields
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 wp_restore_post_revision
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 fix_latest_revision_on_restore
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 xmlrpc_actions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 check_for_early_methods
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 prime_post_cache
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 swap_for_editing
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 uncache_munged_posts
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 make_filterable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 the_posts
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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
36if ( ! defined( 'ABSPATH' ) ) {
37    exit( 0 );
38}
39
40/**
41 * WPCom_Markdown class.
42 */
43class 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">
518jQuery( 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 = isset( $postarr['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
899add_action( 'init', array( WPCom_Markdown::get_instance(), 'load' ) );