No Description

easy-markdown.php 29KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  1. <?php
  2. /*
  3. Plugin Name: Easy Markdown
  4. Plugin URI: https://automattic.com/
  5. Description: Write in Markdown, publish in WordPress
  6. Version: 0.1
  7. Author: Matt Wiebe
  8. Author URI: https://automattic.com/
  9. */
  10. /**
  11. * Copyright (c) Automattic. All rights reserved.
  12. *
  13. * Released under the GPL license
  14. * https://www.opensource.org/licenses/gpl-license.php
  15. *
  16. * This is an add-on for WordPress
  17. * https://wordpress.org/
  18. *
  19. * **********************************************************************
  20. * This program is free software; you can redistribute it and/or modify
  21. * it under the terms of the GNU General Public License as published by
  22. * the Free Software Foundation; either version 2 of the License, or
  23. * (at your option) any later version.
  24. *
  25. * This program is distributed in the hope that it will be useful,
  26. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  27. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  28. * GNU General Public License for more details.
  29. * **********************************************************************
  30. */
  31. class WPCom_Markdown {
  32. const POST_OPTION = 'wpcom_publish_posts_with_markdown';
  33. const COMMENT_OPTION = 'wpcom_publish_comments_with_markdown';
  34. const POST_TYPE_SUPPORT = 'wpcom-markdown';
  35. const IS_MD_META = '_wpcom_is_markdown';
  36. private static $parser;
  37. private static $instance;
  38. // to ensure that our munged posts over xml-rpc are removed from the cache
  39. public $posts_to_uncache = array();
  40. private $monitoring = array( 'post' => array(), 'parent' => array() );
  41. /**
  42. * Yay singletons!
  43. * @return object WPCom_Markdown instance
  44. */
  45. public static function get_instance() {
  46. if ( ! self::$instance )
  47. self::$instance = new self();
  48. return self::$instance;
  49. }
  50. /**
  51. * Kicks things off on `init` action
  52. * @return null
  53. */
  54. public function load() {
  55. $this->add_default_post_type_support();
  56. $this->maybe_load_actions_and_filters();
  57. if ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) {
  58. add_action( 'switch_blog', array( $this, 'maybe_load_actions_and_filters' ), 10, 2 );
  59. }
  60. add_action( 'admin_init', array( $this, 'register_setting' ) );
  61. add_action( 'admin_init', array( $this, 'maybe_unload_for_bulk_edit' ) );
  62. if ( current_theme_supports( 'o2' ) || class_exists( 'P2' ) ) {
  63. $this->add_o2_helpers();
  64. }
  65. }
  66. /**
  67. * If we're in a bulk edit session, unload so that we don't lose our markdown metadata
  68. * @return null
  69. */
  70. public function maybe_unload_for_bulk_edit() {
  71. if ( isset( $_REQUEST['bulk_edit'] ) && $this->is_posting_enabled() ) {
  72. $this->unload_markdown_for_posts();
  73. }
  74. }
  75. /**
  76. * Called on init and fires on switch_blog to decide if our actions and filters
  77. * should be running.
  78. * @param int|null $new_blog_id New blog ID
  79. * @param int|null $old_blog_id Old blog ID
  80. * @return null
  81. */
  82. public function maybe_load_actions_and_filters( $new_blog_id = null, $old_blog_id = null ) {
  83. // When WP sites are being installed, the options table is not available yet.
  84. if ( function_exists( 'wp_installing' ) && wp_installing() ) {
  85. return;
  86. }
  87. // If this is a switch_to_blog call, and the blog isn't changing, we'll already be loaded
  88. if ( $new_blog_id && $new_blog_id === $old_blog_id ) {
  89. return;
  90. }
  91. if ( $this->is_posting_enabled() ) {
  92. $this->load_markdown_for_posts();
  93. } else {
  94. $this->unload_markdown_for_posts();
  95. }
  96. if ( $this->is_commenting_enabled() ) {
  97. $this->load_markdown_for_comments();
  98. } else {
  99. $this->unload_markdown_for_comments();
  100. }
  101. }
  102. /**
  103. * Set up hooks for enabling Markdown conversion on posts
  104. * @return null
  105. */
  106. public function load_markdown_for_posts() {
  107. add_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ), 10, 2 );
  108. add_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
  109. add_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
  110. add_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10, 2 );
  111. add_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10, 2 );
  112. add_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10, 2 );
  113. add_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10, 2 );
  114. add_filter( '_wp_post_revision_fields', array( $this, '_wp_post_revision_fields' ) );
  115. add_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
  116. add_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
  117. if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
  118. $this->check_for_early_methods();
  119. }
  120. }
  121. /**
  122. * Removes hooks to disable Markdown conversion on posts
  123. * @return null
  124. */
  125. public function unload_markdown_for_posts() {
  126. remove_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ) );
  127. remove_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
  128. remove_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
  129. remove_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10, 2 );
  130. remove_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10, 2 );
  131. remove_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10, 2 );
  132. remove_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10, 2 );
  133. remove_filter( '_wp_post_revision_fields', array( $this, '_wp_post_revision_fields' ) );
  134. remove_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
  135. remove_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
  136. }
  137. /**
  138. * Set up hooks for enabling Markdown conversion on comments
  139. * @return null
  140. */
  141. protected function load_markdown_for_comments() {
  142. // Use priority 9 so that Markdown runs before KSES, which can clean up
  143. // any munged HTML.
  144. add_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
  145. }
  146. /**
  147. * Removes hooks to disable Markdown conversion
  148. * @return null
  149. */
  150. protected function unload_markdown_for_comments() {
  151. remove_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
  152. }
  153. /**
  154. * o2 does some of what we do. Let's take precedence.
  155. * @return null
  156. */
  157. public function add_o2_helpers() {
  158. if ( $this->is_posting_enabled() ) {
  159. add_filter( 'content_save_pre', array( $this, 'o2_escape_lists' ), 1 );
  160. }
  161. add_filter( 'o2_preview_post', array( $this, 'o2_preview_post' ) );
  162. add_filter( 'o2_preview_comment', array( $this, 'o2_preview_comment' ) );
  163. add_filter( 'wpcom_markdown_transform_pre', array( $this, 'o2_unescape_lists' ) );
  164. add_filter( 'wpcom_untransformed_content', array( $this, 'o2_unescape_lists' ) );
  165. }
  166. /**
  167. * If Markdown is enabled for posts on this blog, filter the text for o2 previews
  168. * @param string $text Post text
  169. * @return string Post text transformed through the magic of Markdown
  170. */
  171. public function o2_preview_post( $text ) {
  172. if ( $this->is_posting_enabled() ) {
  173. $text = $this->transform( $text, array( 'unslash' => false ) );
  174. }
  175. return $text;
  176. }
  177. /**
  178. * If Markdown is enabled for comments on this blog, filter the text for o2 previews
  179. * @param string $text Comment text
  180. * @return string Comment text transformed through the magic of Markdown
  181. */
  182. public function o2_preview_comment( $text ) {
  183. if ( $this->is_commenting_enabled() ) {
  184. $text = $this->transform( $text, array( 'unslash' => false ) );
  185. }
  186. return $text;
  187. }
  188. /**
  189. * Escapes lists so that o2 doesn't trounce them
  190. * @param string $text Post/comment text
  191. * @return string Text escaped with HTML entity for asterisk
  192. */
  193. public function o2_escape_lists( $text ) {
  194. return preg_replace( '/^\\* /um', '&#42; ', $text );
  195. }
  196. /**
  197. * Unescapes the token we inserted on o2_escape_lists
  198. * @param string $text Post/comment text with HTML entities for asterisks
  199. * @return string Text with the HTML entity removed
  200. */
  201. public function o2_unescape_lists( $text ) {
  202. return preg_replace( '/^[&]\#042; /um', '* ', $text );
  203. }
  204. /**
  205. * Preserve code blocks from being munged by KSES before they have a chance
  206. * @param string $text post content
  207. * @return string post content with code blocks escaped
  208. */
  209. public function preserve_code_blocks( $text ) {
  210. return $this->get_parser()->codeblock_preserve( $text );
  211. }
  212. /**
  213. * Remove KSES if it's there. Store the result to manually invoke later if needed.
  214. * @return null
  215. */
  216. public function maybe_remove_kses() {
  217. // Filters return true if they existed before you removed them
  218. if ( $this->is_posting_enabled() )
  219. $this->kses = remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' ) && remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
  220. }
  221. /**
  222. * Add our Writing and Discussion settings.
  223. * @return null
  224. */
  225. public function register_setting() {
  226. add_settings_field( self::POST_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'post_field' ), 'writing' );
  227. register_setting( 'writing', self::POST_OPTION, array( $this, 'sanitize_setting') );
  228. add_settings_field( self::COMMENT_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'comment_field' ), 'discussion' );
  229. register_setting( 'discussion', self::COMMENT_OPTION, array( $this, 'sanitize_setting') );
  230. }
  231. /**
  232. * Sanitize setting. Don't really want to store "on" value, so we'll store "1" instead!
  233. * @param string $input Value received by settings API via $_POST
  234. * @return bool Cast to boolean.
  235. */
  236. public function sanitize_setting( $input ) {
  237. return (bool) $input;
  238. }
  239. /**
  240. * Prints HTML for the Writing setting
  241. * @return null
  242. */
  243. public function post_field() {
  244. printf(
  245. '<label><input name="%s" id="%s" type="checkbox"%s /> %s</label><p class="description">%s</p>',
  246. self::POST_OPTION,
  247. self::POST_OPTION,
  248. checked( $this->is_posting_enabled(), true, false ),
  249. esc_html__( 'Use Markdown for posts and pages.', 'jetpack' ),
  250. sprintf( '<a href="%s">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
  251. );
  252. }
  253. /**
  254. * Prints HTML for the Discussion setting
  255. * @return null
  256. */
  257. public function comment_field() {
  258. printf(
  259. '<label><input name="%s" id="%s" type="checkbox"%s /> %s</label><p class="description">%s</p>',
  260. self::COMMENT_OPTION,
  261. self::COMMENT_OPTION,
  262. checked( $this->is_commenting_enabled(), true, false ),
  263. esc_html__( 'Use Markdown for comments.', 'jetpack' ),
  264. sprintf( '<a href="%s">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
  265. );
  266. }
  267. /**
  268. * Get the support url for Markdown
  269. * @uses apply_filters
  270. * @return string support url
  271. */
  272. protected function get_support_url() {
  273. /**
  274. * Filter the Markdown support URL.
  275. *
  276. * @module markdown
  277. *
  278. * @since 2.8.0
  279. *
  280. * @param string $url Markdown support URL.
  281. */
  282. return apply_filters( 'easy_markdown_support_url', 'https://en.support.wordpress.com/markdown-quick-reference/' );
  283. }
  284. /**
  285. * Is Mardown conversion for posts enabled?
  286. * @return boolean
  287. */
  288. public function is_posting_enabled() {
  289. return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::POST_OPTION, '' );
  290. }
  291. /**
  292. * Is Markdown conversion for comments enabled?
  293. * @return boolean
  294. */
  295. public function is_commenting_enabled() {
  296. return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::COMMENT_OPTION, '' );
  297. }
  298. /**
  299. * Check if a $post_id has Markdown enabled
  300. * @param int $post_id A post ID.
  301. * @return boolean
  302. */
  303. public function is_markdown( $post_id ) {
  304. return get_metadata( 'post', $post_id, self::IS_MD_META, true );
  305. }
  306. /**
  307. * Set Markdown as enabled on a post_id. We skip over update_postmeta so we
  308. * can sneakily set metadata on post revisions, which we need.
  309. * @param int $post_id A post ID.
  310. * @return bool The metadata was successfully set.
  311. */
  312. protected function set_as_markdown( $post_id ) {
  313. return update_metadata( 'post', $post_id, self::IS_MD_META, true );
  314. }
  315. /**
  316. * Get our Markdown parser object, optionally requiring all of our needed classes and
  317. * instantiating our parser.
  318. * @return object WPCom_GHF_Markdown_Parser instance.
  319. */
  320. public function get_parser() {
  321. if ( ! self::$parser ) {
  322. jetpack_require_lib( 'markdown' );
  323. self::$parser = new WPCom_GHF_Markdown_Parser;
  324. }
  325. return self::$parser;
  326. }
  327. /**
  328. * We don't want Markdown conversion all over the place.
  329. * @return null
  330. */
  331. public function add_default_post_type_support() {
  332. add_post_type_support( 'post', self::POST_TYPE_SUPPORT );
  333. add_post_type_support( 'page', self::POST_TYPE_SUPPORT );
  334. add_post_type_support( 'revision', self::POST_TYPE_SUPPORT );
  335. }
  336. /**
  337. * Figure out the post type of the post screen we're on
  338. * @return string Current post_type
  339. */
  340. protected function get_post_screen_post_type() {
  341. global $pagenow;
  342. if ( 'post-new.php' === $pagenow )
  343. return ( isset( $_GET['post_type'] ) ) ? $_GET['post_type'] : 'post';
  344. if ( isset( $_GET['post'] ) ) {
  345. $post = get_post( (int) $_GET['post'] );
  346. if ( is_object( $post ) && isset( $post->post_type ) )
  347. return $post->post_type;
  348. }
  349. return 'post';
  350. }
  351. /**
  352. * Swap post_content and post_content_filtered for editing
  353. * @param string $content Post content
  354. * @param int $id post ID
  355. * @return string Swapped content
  356. */
  357. public function edit_post_content( $content, $id ) {
  358. if ( $this->is_markdown( $id ) ) {
  359. $post = get_post( $id );
  360. if ( $post && ! empty( $post->post_content_filtered ) ) {
  361. $post = $this->swap_for_editing( $post );
  362. return $post->post_content;
  363. }
  364. }
  365. return $content;
  366. }
  367. /**
  368. * Swap post_content_filtered and post_content for editing
  369. * @param string $content Post content_filtered
  370. * @param int $id post ID
  371. * @return string Swapped content
  372. */
  373. public function edit_post_content_filtered( $content, $id ) {
  374. // if markdown was disabled, let's turn this off
  375. if ( ! $this->is_posting_enabled() && $this->is_markdown( $id ) ) {
  376. $post = get_post( $id );
  377. if ( $post && ! empty( $post->post_content_filtered ) )
  378. $content = '';
  379. }
  380. return $content;
  381. }
  382. /**
  383. * Some tags are allowed to have a 'markdown' attribute, allowing them to contain Markdown.
  384. * We need to tell KSES about those tags.
  385. * @param array $tags List of tags that KSES allows.
  386. * @param string $context The context that KSES is allowing these tags.
  387. * @return array The tags that KSES allows, with our extra 'markdown' parameter where necessary.
  388. */
  389. public function wp_kses_allowed_html( $tags, $context ) {
  390. if ( 'post' !== $context ) {
  391. return $tags;
  392. }
  393. $re = '/' . $this->get_parser()->contain_span_tags_re . '/';
  394. foreach ( $tags as $tag => $attributes ) {
  395. if ( preg_match( $re, $tag ) ) {
  396. $attributes['markdown'] = true;
  397. $tags[ $tag ] = $attributes;
  398. }
  399. }
  400. return $tags;
  401. }
  402. /**
  403. * TinyMCE needs to know not to strip the 'markdown' attribute. Unfortunately, it doesn't
  404. * really offer a nice API for allowed attributes, so we have to manually add it
  405. * to the schema instead.
  406. */
  407. public function after_wp_tiny_mce() {
  408. ?>
  409. <script type="text/javascript">
  410. jQuery( function() {
  411. ( 'undefined' !== typeof tinymce ) && tinymce.on( 'AddEditor', function( event ) {
  412. event.editor.on( 'BeforeSetContent', function( event ) {
  413. var editor = event.target;
  414. Object.keys( editor.schema.elements ).forEach( function( key, index ) {
  415. editor.schema.elements[ key ].attributes['markdown'] = {};
  416. editor.schema.elements[ key ].attributesOrder.push( 'markdown' );
  417. } );
  418. } );
  419. }, true );
  420. } );
  421. </script>
  422. <?php
  423. }
  424. /**
  425. * Magic happens here. Markdown is converted and stored on post_content. Original Markdown is stored
  426. * in post_content_filtered so that we can continue editing as Markdown.
  427. * @param array $post_data The post data that will be inserted into the DB. Slashed.
  428. * @param array $postarr All the stuff that was in $_POST.
  429. * @return array $post_data with post_content and post_content_filtered modified
  430. */
  431. public function wp_insert_post_data( $post_data, $postarr ) {
  432. // $post_data array is slashed!
  433. $post_id = isset( $postarr['ID'] ) ? $postarr['ID'] : false;
  434. // bail early if markdown is disabled or this post type is unsupported.
  435. if ( ! $this->is_posting_enabled() || ! post_type_supports( $post_data['post_type'], self::POST_TYPE_SUPPORT ) ) {
  436. // it's disabled, but maybe this *was* a markdown post before.
  437. if ( $this->is_markdown( $post_id ) && ! empty( $post_data['post_content_filtered'] ) ) {
  438. $post_data['post_content_filtered'] = '';
  439. }
  440. // we have no context to determine supported post types in the `post_content_pre` hook,
  441. // which already ran to sanitize code blocks. Undo that.
  442. $post_data['post_content'] = $this->get_parser()->codeblock_restore( $post_data['post_content'] );
  443. return $post_data;
  444. }
  445. // rejigger post_content and post_content_filtered
  446. // revisions are already in the right place, except when we're restoring, but that's taken care of elsewhere
  447. // also prevent quick edit feature from overriding already-saved markdown (issue https://github.com/Automattic/jetpack/issues/636)
  448. if ( 'revision' !== $post_data['post_type'] && ! isset( $_POST['_inline_edit'] ) ) {
  449. /**
  450. * Filter the original post content passed to Markdown.
  451. *
  452. * @module markdown
  453. *
  454. * @since 2.8.0
  455. *
  456. * @param string $post_data['post_content'] Untransformed post content.
  457. */
  458. $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
  459. $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_id ) );
  460. /** This filter is already documented in core/wp-includes/default-filters.php */
  461. $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
  462. } elseif ( 0 === strpos( $post_data['post_name'], $post_data['post_parent'] . '-autosave' ) ) {
  463. // autosaves for previews are weird
  464. /** This filter is already documented in modules/markdown/easy-markdown.php */
  465. $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
  466. $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_data['post_parent'] ) );
  467. /** This filter is already documented in core/wp-includes/default-filters.php */
  468. $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
  469. }
  470. // set as markdown on the wp_insert_post hook later
  471. if ( $post_id )
  472. $this->monitoring['post'][ $post_id ] = true;
  473. else
  474. $this->monitoring['content'] = wp_unslash( $post_data['post_content'] );
  475. if ( 'revision' === $postarr['post_type'] && $this->is_markdown( $postarr['post_parent'] ) )
  476. $this->monitoring['parent'][ $postarr['post_parent'] ] = true;
  477. return $post_data;
  478. }
  479. /**
  480. * Calls on wp_insert_post action, after wp_insert_post_data. This way we can
  481. * still set postmeta on our revisions after it's all been deleted.
  482. * @param int $post_id The post ID that has just been added/updated
  483. * @return null
  484. */
  485. public function wp_insert_post( $post_id ) {
  486. $post_parent = get_post_field( 'post_parent', $post_id );
  487. // this didn't have an ID yet. Compare the content that was just saved.
  488. if ( isset( $this->monitoring['content'] ) && $this->monitoring['content'] === get_post_field( 'post_content', $post_id ) ) {
  489. unset( $this->monitoring['content'] );
  490. $this->set_as_markdown( $post_id );
  491. }
  492. if ( isset( $this->monitoring['post'][$post_id] ) ) {
  493. unset( $this->monitoring['post'][$post_id] );
  494. $this->set_as_markdown( $post_id );
  495. } elseif ( isset( $this->monitoring['parent'][$post_parent] ) ) {
  496. unset( $this->monitoring['parent'][$post_parent] );
  497. $this->set_as_markdown( $post_id );
  498. }
  499. }
  500. /**
  501. * Run a comment through Markdown. Easy peasy.
  502. * @param string $content
  503. * @return string
  504. */
  505. public function pre_comment_content( $content ) {
  506. return $this->transform( $content, array(
  507. 'id' => $this->comment_hash( $content ),
  508. ) );
  509. }
  510. protected function comment_hash( $content ) {
  511. return 'c-' . substr( md5( $content ), 0, 8 );
  512. }
  513. /**
  514. * Markdown conversion. Some DRYness for repetitive tasks.
  515. * @param string $text Content to be run through Markdown
  516. * @param array $args Arguments, with keys:
  517. * id: provide a string to prefix footnotes with a unique identifier
  518. * unslash: when true, expects and returns slashed data
  519. * decode_code_blocks: when true, assume that text in fenced code blocks is already
  520. * HTML encoded and should be decoded before being passed to Markdown, which does
  521. * its own encoding.
  522. * @return string Markdown-processed content
  523. */
  524. public function transform( $text, $args = array() ) {
  525. // If this contains Gutenberg content, let's keep it intact.
  526. if ( has_blocks( $text ) ) {
  527. return $text;
  528. }
  529. $args = wp_parse_args( $args, array(
  530. 'id' => false,
  531. 'unslash' => true,
  532. 'decode_code_blocks' => ! $this->get_parser()->use_code_shortcode
  533. ) );
  534. // probably need to unslash
  535. if ( $args['unslash'] )
  536. $text = wp_unslash( $text );
  537. /**
  538. * Filter the content to be run through Markdown, before it's transformed by Markdown.
  539. *
  540. * @module markdown
  541. *
  542. * @since 2.8.0
  543. *
  544. * @param string $text Content to be run through Markdown
  545. * @param array $args Array of Markdown options.
  546. */
  547. $text = apply_filters( 'wpcom_markdown_transform_pre', $text, $args );
  548. // ensure our paragraphs are separated
  549. $text = str_replace( array( '</p><p>', "</p>\n<p>" ), "</p>\n\n<p>", $text );
  550. // visual editor likes to add <p>s. Buh-bye.
  551. $text = $this->get_parser()->unp( $text );
  552. // sometimes we get an encoded > at start of line, breaking blockquotes
  553. $text = preg_replace( '/^&gt;/m', '>', $text );
  554. // prefixes are because we need to namespace footnotes by post_id
  555. $this->get_parser()->fn_id_prefix = $args['id'] ? $args['id'] . '-' : '';
  556. // If we're not using the code shortcode, prevent over-encoding.
  557. if ( $args['decode_code_blocks'] ) {
  558. $text = $this->get_parser()->codeblock_restore( $text );
  559. }
  560. // Transform it!
  561. $text = $this->get_parser()->transform( $text );
  562. // Fix footnotes - kses doesn't like the : IDs it supplies
  563. $text = preg_replace( '/((id|href)="#?fn(ref)?):/', "$1-", $text );
  564. // Markdown inserts extra spaces to make itself work. Buh-bye.
  565. $text = rtrim( $text );
  566. /**
  567. * Filter the content to be run through Markdown, after it was transformed by Markdown.
  568. *
  569. * @module markdown
  570. *
  571. * @since 2.8.0
  572. *
  573. * @param string $text Content to be run through Markdown
  574. * @param array $args Array of Markdown options.
  575. */
  576. $text = apply_filters( 'wpcom_markdown_transform_post', $text, $args );
  577. // probably need to re-slash
  578. if ( $args['unslash'] )
  579. $text = wp_slash( $text );
  580. return $text;
  581. }
  582. /**
  583. * Shows Markdown in the Revisions screen, and ensures that post_content_filtered
  584. * is maintained on revisions
  585. * @param array $fields Post fields pertinent to revisions
  586. * @return array Modified array to include post_content_filtered
  587. */
  588. public function _wp_post_revision_fields( $fields ) {
  589. $fields['post_content_filtered'] = __( 'Markdown content', 'jetpack' );
  590. return $fields;
  591. }
  592. /**
  593. * Do some song and dance to keep all post_content and post_content_filtered content
  594. * in the expected place when a post revision is restored.
  595. * @param int $post_id The post ID have a restore done to it
  596. * @param int $revision_id The revision ID being restored
  597. * @return null
  598. */
  599. public function wp_restore_post_revision( $post_id, $revision_id ) {
  600. if ( $this->is_markdown( $revision_id ) ) {
  601. $revision = get_post( $revision_id, ARRAY_A );
  602. $post = get_post( $post_id, ARRAY_A );
  603. $post['post_content'] = $revision['post_content_filtered']; // Yes, we put it in post_content, because our wp_insert_post_data() expects that
  604. // set this flag so we can restore the post_content_filtered on the last revision later
  605. $this->monitoring['restore'] = true;
  606. // let's not make a revision of our fixing update
  607. add_filter( 'wp_revisions_to_keep', '__return_false', 99 );
  608. wp_update_post( $post );
  609. $this->fix_latest_revision_on_restore( $post_id );
  610. remove_filter( 'wp_revisions_to_keep', '__return_false', 99 );
  611. }
  612. }
  613. /**
  614. * We need to ensure the last revision has Markdown, not HTML in its post_content_filtered
  615. * column after a restore.
  616. * @param int $post_id The post ID that was just restored.
  617. * @return null
  618. */
  619. protected function fix_latest_revision_on_restore( $post_id ) {
  620. global $wpdb;
  621. $post = get_post( $post_id );
  622. $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 ) );
  623. $last_revision->post_content_filtered = $post->post_content_filtered;
  624. wp_insert_post( (array) $last_revision );
  625. }
  626. /**
  627. * Kicks off magic for an XML-RPC session. We want to keep editing Markdown
  628. * and publishing HTML.
  629. * @param string $xmlrpc_method The current XML-RPC method
  630. * @return null
  631. */
  632. public function xmlrpc_actions( $xmlrpc_method ) {
  633. switch ( $xmlrpc_method ) {
  634. case 'metaWeblog.getRecentPosts':
  635. case 'wp.getPosts':
  636. case 'wp.getPages':
  637. add_action( 'parse_query', array( $this, 'make_filterable' ), 10, 1 );
  638. break;
  639. case 'wp.getPost':
  640. $this->prime_post_cache();
  641. break;
  642. }
  643. }
  644. /**
  645. * metaWeblog.getPost and wp.getPage fire xmlrpc_call action *after* get_post() is called.
  646. * So, we have to detect those methods and prime the post cache early.
  647. * @return null
  648. */
  649. protected function check_for_early_methods() {
  650. $raw_post_data = file_get_contents( "php://input" );
  651. if ( false === strpos( $raw_post_data, 'metaWeblog.getPost' )
  652. && false === strpos( $raw_post_data, 'wp.getPage' ) ) {
  653. return;
  654. }
  655. include_once( ABSPATH . WPINC . '/class-IXR.php' );
  656. $message = new IXR_Message( $raw_post_data );
  657. $message->parse();
  658. $post_id_position = 'metaWeblog.getPost' === $message->methodName ? 0 : 1;
  659. $this->prime_post_cache( $message->params[ $post_id_position ] );
  660. }
  661. /**
  662. * Prime the post cache with swapped post_content. This is a sneaky way of getting around
  663. * the fact that there are no good hooks to call on the *.getPost xmlrpc methods.
  664. *
  665. * @return null
  666. */
  667. private function prime_post_cache( $post_id = false ) {
  668. global $wp_xmlrpc_server;
  669. if ( ! $post_id ) {
  670. $post_id = $wp_xmlrpc_server->message->params[3];
  671. }
  672. // prime the post cache
  673. if ( $this->is_markdown( $post_id ) ) {
  674. $post = get_post( $post_id );
  675. if ( ! empty( $post->post_content_filtered ) ) {
  676. wp_cache_delete( $post->ID, 'posts' );
  677. $post = $this->swap_for_editing( $post );
  678. wp_cache_add( $post->ID, $post, 'posts' );
  679. $this->posts_to_uncache[] = $post_id;
  680. }
  681. }
  682. // uncache munged posts if using a persistent object cache
  683. if ( wp_using_ext_object_cache() ) {
  684. add_action( 'shutdown', array( $this, 'uncache_munged_posts' ) );
  685. }
  686. }
  687. /**
  688. * Swaps `post_content_filtered` back to `post_content` for editing purposes.
  689. * @param object $post WP_Post object
  690. * @return object WP_Post object with swapped `post_content_filtered` and `post_content`
  691. */
  692. protected function swap_for_editing( $post ) {
  693. $markdown = $post->post_content_filtered;
  694. // unencode encoded code blocks
  695. $markdown = $this->get_parser()->codeblock_restore( $markdown );
  696. // restore beginning of line blockquotes
  697. $markdown = preg_replace( '/^&gt; /m', '> ', $markdown );
  698. $post->post_content_filtered = $post->post_content;
  699. $post->post_content = $markdown;
  700. return $post;
  701. }
  702. /**
  703. * We munge the post cache to serve proper markdown content to XML-RPC clients.
  704. * Uncache these after the XML-RPC session ends.
  705. * @return null
  706. */
  707. public function uncache_munged_posts() {
  708. // $this context gets lost in testing sometimes. Weird.
  709. foreach( WPCom_Markdown::get_instance()->posts_to_uncache as $post_id ) {
  710. wp_cache_delete( $post_id, 'posts' );
  711. }
  712. }
  713. /**
  714. * Since *.(get)?[Rr]ecentPosts calls get_posts with suppress filters on, we need to
  715. * turn them back on so that we can swap things for editing.
  716. * @param object $wp_query WP_Query object
  717. * @return null
  718. */
  719. public function make_filterable( $wp_query ) {
  720. $wp_query->set( 'suppress_filters', false );
  721. add_action( 'the_posts', array( $this, 'the_posts' ), 10, 2 );
  722. }
  723. /**
  724. * Swaps post_content and post_content_filtered for editing.
  725. * @param array $posts Posts returned by the just-completed query
  726. * @param object $wp_query Current WP_Query object
  727. * @return array Modified $posts
  728. */
  729. public function the_posts( $posts, $wp_query ) {
  730. foreach ( $posts as $key => $post ) {
  731. if ( $this->is_markdown( $post->ID ) && ! empty( $posts[ $key ]->post_content_filtered ) ) {
  732. $markdown = $posts[ $key ]->post_content_filtered;
  733. $posts[ $key ]->post_content_filtered = $posts[ $key ]->post_content;
  734. $posts[ $key ]->post_content = $markdown;
  735. }
  736. }
  737. return $posts;
  738. }
  739. /**
  740. * Singleton silence is golden
  741. */
  742. private function __construct() {}
  743. }
  744. add_action( 'init', array( WPCom_Markdown::get_instance(), 'load' ) );