if ( preg_match( $re, $tag ) ) {
$attributes['markdown'] = true;
$tags[ $tag ] = $attributes;
* TinyMCE needs to know not to strip the 'markdown' attribute. Unfortunately, it doesn't
* really offer a nice API for allowed attributes, so we have to manually add it
public function after_wp_tiny_mce() {
<script type="text/javascript">
( 'undefined' !== typeof tinymce ) && tinymce.on( 'AddEditor', function( event ) {
event.editor.on( 'BeforeSetContent', function( event ) {
var editor = event.target;
Object.keys( editor.schema.elements ).forEach( function( key, index ) {
editor.schema.elements[ key ].attributes['markdown'] = {};
editor.schema.elements[ key ].attributesOrder.push( 'markdown' );
* Magic happens here. Markdown is converted and stored on post_content. Original Markdown is stored
* in post_content_filtered so that we can continue editing as Markdown.
* @param array $post_data The post data that will be inserted into the DB. Slashed.
* @param array $postarr All the stuff that was in $_POST.
* @return array $post_data with post_content and post_content_filtered modified
public function wp_insert_post_data( $post_data, $postarr ) {
// $post_data array is slashed!
$post_id = isset( $postarr['ID'] ) ? $postarr['ID'] : false;
// bail early if markdown is disabled or this post type is unsupported.
if ( ! $this->is_posting_enabled() || ! post_type_supports( $post_data['post_type'], self::POST_TYPE_SUPPORT ) ) {
// it's disabled, but maybe this *was* a markdown post before.
if ( $this->is_markdown( $post_id ) && ! empty( $post_data['post_content_filtered'] ) ) {
$post_data['post_content_filtered'] = '';
// we have no context to determine supported post types in the `post_content_pre` hook,
// which already ran to sanitize code blocks. Undo that.
$post_data['post_content'] = $this->get_parser()->codeblock_restore( $post_data['post_content'] );
// rejigger post_content and post_content_filtered
// revisions are already in the right place, except when we're restoring, but that's taken care of elsewhere
// also prevent quick edit feature from overriding already-saved markdown (issue https://github.com/Automattic/jetpack/issues/636).
if ( 'revision' !== $post_data['post_type'] && ! isset( $_POST['_inline_edit'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
* Filter the original post content passed to Markdown.
* @param string $post_data['post_content'] Untransformed post content.
$post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
$post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_id ) );
/** This filter is already documented in core/wp-includes/default-filters.php */
$post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
} elseif ( str_starts_with( $post_data['post_name'], $post_data['post_parent'] . '-autosave' ) ) {
// autosaves for previews are weird.
/** This filter is already documented in modules/markdown/easy-markdown.php */
$post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
$post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_data['post_parent'] ) );
/** This filter is already documented in core/wp-includes/default-filters.php */
$post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
// set as markdown on the wp_insert_post hook later.
$this->monitoring['post'][ $post_id ] = true;
$this->monitoring['content'] = wp_unslash( $post_data['post_content'] );
if ( 'revision' === $postarr['post_type'] && $this->is_markdown( $postarr['post_parent'] ) ) {
$this->monitoring['parent'][ $postarr['post_parent'] ] = true;
* Calls on wp_insert_post action, after wp_insert_post_data. This way we can
* still set postmeta on our revisions after it's all been deleted.
* @param int $post_id The post ID that has just been added/updated.
public function wp_insert_post( $post_id ) {
$post_parent = get_post_field( 'post_parent', $post_id );
// this didn't have an ID yet. Compare the content that was just saved.
if ( isset( $this->monitoring['content'] ) && get_post_field( 'post_content', $post_id ) === $this->monitoring['content'] ) {
unset( $this->monitoring['content'] );
$this->set_as_markdown( $post_id );
if ( isset( $this->monitoring['post'][ $post_id ] ) ) {
unset( $this->monitoring['post'][ $post_id ] );
$this->set_as_markdown( $post_id );
} elseif ( isset( $this->monitoring['parent'][ $post_parent ] ) ) {
unset( $this->monitoring['parent'][ $post_parent ] );
$this->set_as_markdown( $post_id );
* Run a comment through Markdown. Easy peasy.
* @param string $content - the content.
public function pre_comment_content( $content ) {
'id' => $this->comment_hash( $content ),
* @param string $content - the content of the comment.
protected function comment_hash( $content ) {
return 'c-' . substr( md5( $content ), 0, 8 );
* Markdown conversion. Some DRYness for repetitive tasks.
* @param string $text Content to be run through Markdown.
* @param array $args Arguments, with keys:
* id: provide a string to prefix footnotes with a unique identifier
* unslash: when true, expects and returns slashed data
* decode_code_blocks: when true, assume that text in fenced code blocks is already
* HTML encoded and should be decoded before being passed to Markdown, which does
* @return string Markdown-processed content
public function transform( $text, $args = array() ) {
// If this contains Gutenberg content, let's keep it intact.
if ( has_blocks( $text ) ) {
'decode_code_blocks' => ! $this->get_parser()->use_code_shortcode,
// probably need to unslash.
if ( $args['unslash'] ) {
$text = wp_unslash( $text );
* Filter the content to be run through Markdown, before it's transformed by Markdown.
* @param string $text Content to be run through Markdown
* @param array $args Array of Markdown options.
$text = apply_filters( 'wpcom_markdown_transform_pre', $text, $args ) ?? '';
// ensure our paragraphs are separated.
$text = str_replace( array( '</p><p>', "</p>\n<p>" ), "</p>\n\n<p>", $text );
// visual editor likes to add <p>s. Buh-bye.
$text = $this->get_parser()->unp( $text );
// sometimes we get an encoded > at start of line, breaking blockquotes.
$text = preg_replace( '/^>/m', '>', $text );
// prefixes are because we need to namespace footnotes by post_id.
$this->get_parser()->fn_id_prefix = $args['id'] ? $args['id'] . '-' : '';
// If we're not using the code shortcode, prevent over-encoding.
if ( $args['decode_code_blocks'] ) {
$text = $this->get_parser()->codeblock_restore( $text );
$text = $this->get_parser()->transform( $text );
// Fix footnotes - kses doesn't like the : IDs it supplies.
$text = preg_replace( '/((id|href)="#?fn(ref)?):/', '$1-', $text );
// Markdown inserts extra spaces to make itself work. Buh-bye.
* Filter the content to be run through Markdown, after it was transformed by Markdown.
* @param string $text Content to be run through Markdown
* @param array $args Array of Markdown options.
$text = apply_filters( 'wpcom_markdown_transform_post', $text, $args );
// probably need to re-slash.
if ( $args['unslash'] ) {
$text = wp_slash( $text );
* Shows Markdown in the Revisions screen, and ensures that post_content_filtered
* is maintained on revisions
* @param array $fields Post fields pertinent to revisions.
public function wp_post_revision_fields( $fields ) {
$fields['post_content_filtered'] = __( 'Markdown content', 'jetpack' );
* Do some song and dance to keep all post_content and post_content_filtered content
* in the expected place when a post revision is restored.
* @param int $post_id The post ID have a restore done to it.
* @param int $revision_id The revision ID being restored.
public function wp_restore_post_revision( $post_id, $revision_id ) {
if ( $this->is_markdown( $revision_id ) ) {
$revision = get_post( $revision_id, ARRAY_A );
$post = get_post( $post_id, ARRAY_A );
$post['post_content'] = $revision['post_content_filtered']; // Yes, we put it in post_content, because our wp_insert_post_data() expects that.
// set this flag so we can restore the post_content_filtered on the last revision later.
$this->monitoring['restore'] = true;
// let's not make a revision of our fixing update.
add_filter( 'wp_revisions_to_keep', '__return_false', 99 );
$this->fix_latest_revision_on_restore( $post_id );
remove_filter( 'wp_revisions_to_keep', '__return_false', 99 );
* We need to ensure the last revision has Markdown, not HTML in its post_content_filtered
* column after a restore.
* @param int $post_id The post ID that was just restored.
protected function fix_latest_revision_on_restore( $post_id ) {
$post = get_post( $post_id );
$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 ) );
$last_revision->post_content_filtered = $post->post_content_filtered;
wp_insert_post( (array) $last_revision );
* Kicks off magic for an XML-RPC session. We want to keep editing Markdown
* @param string $xmlrpc_method The current XML-RPC method.
public function xmlrpc_actions( $xmlrpc_method ) {
switch ( $xmlrpc_method ) {
case 'metaWeblog.getRecentPosts':
add_action( 'parse_query', array( $this, 'make_filterable' ), 10, 1 );
$this->prime_post_cache();
* Function metaWeblog.getPost and wp.getPage fire xmlrpc_call action *after* get_post() is called.
* So, we have to detect those methods and prime the post cache early.
protected function check_for_early_methods() {
$raw_post_data = file_get_contents( 'php://input' );
if ( ! str_contains( $raw_post_data, 'metaWeblog.getPost' )
&& ! str_contains( $raw_post_data, 'wp.getPage' ) ) {
include_once ABSPATH . WPINC . '/class-IXR.php';
$message = new IXR_Message( $raw_post_data );
$post_id_position = 'metaWeblog.getPost' === $message->methodName ? 0 : 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$this->prime_post_cache( $message->params[ $post_id_position ] ?? false );
* Prime the post cache with swapped post_content. This is a sneaky way of getting around
* the fact that there are no good hooks to call on the *.getPost xmlrpc methods.
* @param bool $post_id - the post ID that we're priming.
private function prime_post_cache( $post_id = false ) {
global $wp_xmlrpc_server;
if ( isset( $wp_xmlrpc_server->message->params[3] ) ) {
$post_id = $wp_xmlrpc_server->message->params[3];
return; // Exit early if we can't get a valid post_id
if ( $this->is_markdown( $post_id ) ) {
$post = get_post( $post_id );
if ( ! empty( $post->post_content_filtered ) ) {
wp_cache_delete( $post->ID, 'posts' );
$post = $this->swap_for_editing( $post );
wp_cache_add( $post->ID, $post, 'posts' );
$this->posts_to_uncache[] = $post_id;
// uncache munged posts if using a persistent object cache.
if ( wp_using_ext_object_cache() ) {
add_action( 'shutdown', array( $this, 'uncache_munged_posts' ) );
* Swaps `post_content_filtered` back to `post_content` for editing purposes.
* @param object $post WP_Post object.
* @return object WP_Post object with swapped `post_content_filtered` and `post_content`.
protected function swap_for_editing( $post ) {
$markdown = $post->post_content_filtered;
// unencode encoded code blocks.
$markdown = $this->get_parser()->codeblock_restore( $markdown );
// restore beginning of line blockquotes.
$markdown = preg_replace( '/^> /m', '> ', $markdown );
$post->post_content_filtered = $post->post_content;
$post->post_content = $markdown;
* We munge the post cache to serve proper markdown content to XML-RPC clients.
* Uncache these after the XML-RPC session ends.
public function uncache_munged_posts() {
// $this context gets lost in testing sometimes. Weird.
foreach ( self::get_instance()->posts_to_uncache as $post_id ) {
wp_cache_delete( $post_id, 'posts' );
* Since *.(get)?[Rr]ecentPosts calls get_posts with suppress filters on, we need to
* turn them back on so that we can swap things for editing.
* @param object $wp_query WP_Query object.
public function make_filterable( $wp_query ) {
$wp_query->set( 'suppress_filters', false );
add_action( 'the_posts', array( $this, 'the_posts' ), 10, 2 );
* Swaps post_content and post_content_filtered for editing.
* @param array $posts Posts returned by the just-completed query.
* @return array Modified $posts
public function the_posts( $posts ) {
foreach ( $posts as $key => $post ) {
if ( $this->is_markdown( $post->ID ) && ! empty( $posts[ $key ]->post_content_filtered ) ) {
$markdown = $posts[ $key ]->post_content_filtered;
$posts[ $key ]->post_content_filtered = $posts[ $key ]->post_content;
$posts[ $key ]->post_content = $markdown;
* Singleton silence is golden
private function __construct() {}
add_action( 'init', array( WPCom_Markdown::get_instance(), 'load' ) );