diff --git a/blazer-six-gist-oembed.php b/blazer-six-gist-oembed.php index 26d868e..cd67ad4 100644 --- a/blazer-six-gist-oembed.php +++ b/blazer-six-gist-oembed.php @@ -9,581 +9,87 @@ * License: GPLv2 or later * License URI: http://www.gnu.org/licenses/gpl-2.0.html * - * @package Blazer_Six_Gist_oEmbed + * @package BlazerSix\GistoEmbed * @author Brady Vercher + * @author Gary Jones * @copyright Copyright (c) 2012, Blazer Six, Inc. * @license GPL-2.0+ + */ + +// Instantiate main plugin class +if ( ! class_exists( 'Blazer_Six_Gist_oEmbed' ) ) { + require( plugin_dir_path( __FILE__ ) . 'class-blazer-six-gist-oembed.php' ); +} +$gist_oembed = new Blazer_Six_Gist_oEmbed; + +// Instantiate logging class +if ( ! class_exists( 'Blazer_Six_Gist_oEmbed_Log' ) ) { + require( plugin_dir_path( __FILE__ ) . 'class-blazer-six-gist-oembed-log.php' ); +} +$gist_oembed_logger = new Blazer_Six_Gist_oEmbed_Log; + +add_action( 'init', 'blazer_six_gist_oembed_localization' ); +/** + * Support localization for plugin. + * + * @see http://www.geertdedeckere.be/article/loading-wordpress-language-files-the-right-way * - * @todo Feed support: link directly to post, directly to Gist, or wrap in iframe? - * @todo Cache the stylesheet locally. + * @since 1.1.0 */ +function blazer_six_gist_oembed_localization() { + $domain = 'blazersix-gist-oembed'; + // The "plugin_locale" filter is also used in load_plugin_textdomain() + $locale = apply_filters( 'plugin_locale', get_locale(), $domain ); + load_textdomain( $domain, WP_LANG_DIR . '/my-plugin/' . $domain . '-' . $locale . '.mo' ); + load_plugin_textdomain( $domain, false, dirname( plugin_basename( __FILE__ ) ) . '/languages/' ); +} +add_action( 'init', 'blazer_six_gist_oembed_init' ); /** - * Load the plugin when plugins are loaded. + * Set plugin logger class and initialise plugin. + * + * If you want a different logger class, then unhook this function, and hook + * in your own which does what you need. + * + * @since 1.1.0 */ -add_action( 'plugins_loaded', array( 'Blazer_Six_Gist_oEmbed', 'instance' ) ); +function blazer_six_gist_oembed_init() { + global $gist_oembed, $gist_oembed_logger; + $gist_oembed->set_logger( $gist_oembed_logger ); + $gist_oembed->run(); +} +/** @todo Can the following two functions be made static methods of the debug + * bar class, since there is already an inherent dependency? */ + +// Late priority to give Debug Bar plugin chance to initialise +add_action( 'plugins_loaded', 'blazer_six_gist_oembed_add_debug_bar_panel_support', 15 ); /** - * The main plugin class. + * Add optional support for Debug Bar plugin, if enabled * - * @since 1.0.0 + * @return null Return early if Debug Bar plugin not enabled. */ -class Blazer_Six_Gist_oEmbed { - /** - * @access private - * @var Blazer_Six_Gist_oEmbed - */ - private static $instance; - - /** - * Basic log for debug messages. - * - * @access protected - * @var array - */ - protected $debug_log = array(); - - /** - * Key for grouping debug messages by shortcode. - * - * @access protected - * @var int - */ - protected $debug_log_key = 0; - - /** - * Toggle to short-circuit shortcode output and expire its corresponding - * transient so output can be regenerated the next time it is run. - * - * @access protected - * @var bool - */ - protected $expire_transients = false; - - /** - * Main Blazer_Six_Gist_oEmbed instance. - * - * @since 1.1.0 - */ - public static function instance() { - if ( ! self::$instance ) { - self::$instance = new self(); - } - - return self::$instance; +function blazer_six_gist_oembed_add_debug_bar_panel_support() { + if ( ! class_exists( 'Debug_Bar' ) || is_admin() || ! is_admin_bar_showing() ) { + return; } - - /** - * Set up the plugin. - * - * Adds a [gist] shortcode to do the bulk of the heavy lifting. An embed - * handler is registered to mimic oEmbed functionality, but it relies on - * the shortcode for processing. - * - * Old URL Format: https://gist.github.com/{{id}}#file_{{filename}} - * New URL Format: https://gist.github.com/{{id}}#file-{{file_slug}} - * - * @since 1.1.0 - */ - private function __construct() { - // File matching is maintained for backward compatibility, but won't work for the new Gist "bookmark" URLs. - wp_embed_register_handler( 'gist', '#(https://gist\.github\.com/([a-z0-9]+))(?:\#file_(.*))?#i', array( $this, 'wp_embed_handler' ) ); - add_shortcode( 'gist', array( $this, 'shortcode' ) ); - - add_action( 'init', array( $this, 'init' ) ); - add_action( 'post_updated', array( $this, 'expire_gist_transients' ), 10, 3 ); - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - add_filter( 'debug_bar_panels', array( $this, 'add_debug_bar_panel' ) ); - } - } - - /** - * Register the Gist stylesheet so it can be embedded once. - * - * @since 1.0.0 - */ - public function init() { - wp_register_style( 'github-gist', get_option( 'blazersix_gist_embed_stylesheet' ) ); - } - - /** - * WP embed handler to generate a shortcode string from a Gist URL. - * - * Parses Gist URLs for oEmbed support. Returns the value as a shortcode - * string to let the shortcode method handle processing. The value - * returned also doesn't have wpautop() applied, which is a must for - * source code. - * - * @since 1.0.0 - */ - public function wp_embed_handler( $matches, $attr, $url, $rawattr ) { - $shortcode = '[gist'; - - if ( isset( $matches[2] ) && ! empty( $matches[2] ) ) { - $shortcode .= ' id="' . esc_attr( $matches[2] ) . '"'; - } - - // For backward compatibility. - if ( isset( $matches[3] ) && ! empty( $matches[3] ) ) { - $shortcode .= ' file="' . esc_attr( $matches[3] ) . '"'; - } - - $shortcode .= ']'; - - return $shortcode; - } - - /** - * Gist shortcode. - * - * Works with secret Gists, too. - * - * Shortcode attributes: - * - * - id - The Gist id (found in the URL). The only required attribute. - * - embed_stylesheet - Whether the external stylesheet should be enqueued for output in the footer. - * * If the footer is too late, set to false and enqueue the 'github-gist' style before 'wp_head'. - * * Any custom styles should be added to the theme's stylesheet. - * - file - Name of a specific file in a Gist. - * - highlight - Comma-separated list of line numbers to highlight. - * * Ranges can be specified. Ex: 2,4,6-10,12 - * - highlight_color - Background color of highlighted lines. - * * To change it globally, hook into the filter any supply a different color. - * - lines - A range of lines to limit the Gist to. - * * Suited for single file Gists or shortcodes using the 'file' attribute. - * - show_line_numbers - Whether line numbers should be displayed. - * - show_meta - Whether the trailing meta information in default Gist embeds should be displayed. - * - * @since 1.0.0 - * - * @param array $attr Attributes of the shortcode. - * @return string HTML content to display the Gist. - */ - public function shortcode( $attr ) { - global $post; - - $defaults = apply_filters( 'blazersix_gist_shortcode_defaults', array( - 'embed_stylesheet' => apply_filters( 'blazersix_gist_embed_stylesheet_default', true ), - 'file' => '', - 'highlight' => array(), - 'highlight_color' => apply_filters( 'blazersix_gist_embed_highlight_color', '#ffffcc' ), - 'id' => '', - 'lines' => '', - 'show_line_numbers' => true, - 'show_meta' => true - ) ); - - // Sanitize attributes. - $attr = shortcode_atts( $defaults, $attr ); - $attr['embed_stylesheet'] = $this->shortcode_bool( $attr['embed_stylesheet'] ); - $attr['show_line_numbers'] = $this->shortcode_bool( $attr['show_line_numbers'] ); - $attr['show_meta'] = $this->shortcode_bool( $attr['show_meta'] ); - $attr['highlight'] = $this->parse_highlight_arg( $attr['highlight'] ); - $attr['lines'] = $this->parse_line_number_arg( $attr['lines'] ); - - // Short-circuit the shortcode output and just expire the transient. - // This is set to true when posts are updated. - if ( $this->expire_transients ) { - $this->expire_gist_transient( $attr ); - return; - } - - if ( ! empty( $attr['id'] ) ) { - $url = 'https://gist.github.com/' . $attr['id']; - $json_url = $url . '.json'; - } - - // Bail if the JSON endpoint couldn't be determined. - if ( ! isset( $json_url ) ) { - return ''; - } - - if ( $json_url && isset( $post->ID ) ) { - $html = $this->get_gist_html( $json_url, $attr ); - - if ( '{{unknown}}' === $html ) { - return $wp_embed->maybe_make_link( $url ); - } - - // If there was a result, return it. - if ( $html ) { - if ( $attr['embed_stylesheet'] ) { - wp_enqueue_style( 'github-gist' ); - } - - $html = apply_filters( 'blazersix_gist_embed_html', $html, $url, $attr, $post->ID ); - - $this->debug_log( $attr ); - $this->debug_log( $html ); - $this->debug_log_key ++; - - return $html; - } - } - - return ''; - } - - /** - * Helper method to determine if a shortcode attribute is true or false. - * - * @since 1.1.0 - * - * @param string|int|bool $var Attribute value. - * @return bool - */ - public function shortcode_bool( $var ) { - $falsey = array( 'false', '0', 'no', 'n' ); - return ( ! $var || in_array( strtolower( $var ), $falsey ) ) ? false : true; - } - - /** - * Parses and expands the shortcode 'highlight' attribute and returns it - * in a usable format. - * - * @since 1.1.0 - * - * @param string $line_numbers Comma-separated list of line numbers and ranges. - * @return array List of line numbers. - */ - public function parse_highlight_arg( $line_numbers ) { - if ( empty( $line_numbers ) ) { - return null; - } - - // Determine which lines should be highlighted. - $highlight = explode( ',', $line_numbers ); - - // Convert any ranges. - foreach ( $highlight as $key => $num ) { - if ( false !== strpos( $num, '-' ) ) { - unset( $highlight[ $key ] ); - - $range = explode( '-', $num ); - $highlight += range( $range[0], $range[1] ); - } - } - - return array_unique( $highlight ); - } - - /** - * Parses the shortcode 'lines' attribute into min and max values. - * - * @since 1.1.0 - * - * @param string $line_numbers Range of line numbers separated by a dash. - * @return array Array with min and max line numbers. - */ - public function parse_line_number_arg( $line_numbers ) { - if ( empty( $line_numbers ) ) { - return array( 'min' => 0, 'max' => 0 ); - } - - if ( false === strpos( $line_numbers, '-' ) ) { - $range = array_fill_keys( array( 'min', 'max' ), absint( $line_numbers ) ); - } else { - $numbers = array_map( 'absint', explode( '-', $line_numbers ) ); - - $range = array( - 'min' => $numbers[0], - 'max' => $numbers[1] - ); - } - - return $range; - } - - /** - * Retrieve Gist HTML. - * - * Gist HTML can come from one of three different sources: - * - Remote JSON endpoint. - * - Transient. - * - Post meta cache. - * - * When a Gist is intially requested, the HTML is fetched from the JSON - * endpoint and cached in a post meta field. It is then processed to limit - * line numbers, highlight specific lines, and add a few extra classes as - * style hooks. The processed HTML is then stored in a transient using a - * hash of the shortcodes attributes for the key. - * - * On subsequent requests, the HTML is fetched from the transient until it - * expires, then it is requested from the remote URL again. - * - * In the event the HTML can't be fetched from the remote endpoint and the - * transient is expired, the HTML is retrieved from the post meta backup. - * - * This algorithm allows Gist HTML to stay in sync with any changes GitHub - * may make to their markup, while providing a local cache for faster - * retrieval and a backup in case GitHub can't be reached. - * - * @since 1.1.0 - * - * @param string $url The JSON endpoint for the Gist. - * @param array $args List of shortcode attributes. - * @return string Gist HTML or {{unknown}} if it couldn't be determined. - */ - public function get_gist_html( $url, $args ) { - global $post; - - // Add a specific file from a Gist to the URL. - if ( ! empty( $args['file'] ) ) { - $url = add_query_arg( 'file', urlencode( $args['file'] ), $url ); - } - - $post_meta_key = '_gist_embed_' . md5( $url ); - $transient_key = 'gist_embed_' . $this->shortcode_hash( 'gist', $args ); - - $html = get_transient( $transient_key ); - - // Retrieve html from Gist JSON endpoint. - if ( empty( $html ) ) { - $json = $this->fetch_gist( $url ); - - if ( ! empty( $json->div ) ) { - $html = $json->div; - } - - // Update the stylesheet reference. - if ( ! empty( $json->stylesheet ) ) { - update_option( 'blazersix_gist_embed_stylesheet', $json->stylesheet ); - } - - // Failures are cached, too. Update the post to attempt to fetch again. - $html = ( $html ) ? $html : '{{unknown}}'; - $transient_expire = 60 * 60 * 24; - - if ( '{{unknown}}' != $html ) { - // Update the post meta fallback. - // @link http://core.trac.wordpress.org/ticket/21767 - update_post_meta( $post->ID, $post_meta_key, addslashes( $html ) ); - $html = $this->process_gist_html( $html, $args ); - $this->debug_log( '

' . __( 'Output Source: Remote Request', 'blazersix-gist-oembed' ) . '

' ); - } elseif ( $fallback = get_post_meta( $post->ID, $post_meta_key, true ) ) { - // Return the fallback instead of {{unknown}} - $html = $this->process_gist_html( $fallback, $args ); - - // Cache the fallback for an hour. - $transient_expire = 60 * 60; - $this->debug_log( '

' . __( 'Output Source: Post Meta Fallback', 'blazersix-gist-oembed' ) . '

' ); - } else { - $this->debug_log( '' . __( 'Remote call and transient failed and fallback was empty.', 'blazersix-gist-oembed' ) . '' ); - } - - // Cache the processed HTML. - set_transient( $transient_key, $html, $transient_expire ); - } else { - $this->debug_log( '

' . __( 'Output Source: Transient Cache', 'blazersix-gist-oembed' ) . '

' ); - } - - $this->debug_log( '' . __( 'JSON Endpoint:', 'blazersix-gist-oembed' ) . ' ' . $url ); - $this->debug_log( '' . __( 'Post Meta Cache Key:', 'blazersix-gist-oembed' ) . ' ' . $post_meta_key ); - $this->debug_log( '' . __( 'Transient Key:', 'blazersix-gist-oembed' ) . ' ' . $transient_key ); - - return $html; - } - - /** - * Fetch Gist data from its JSON endpoint. - * - * @since 1.1.0 - * - * @param string $url Gist JSON endpoint. - * @return object|bool Gist JSON object or false. - */ - public function fetch_gist( $url ) { - $this->debug_log( '' . __( 'Doing remote request:', 'blazersix-gist-oembed' ) . '
' . $url ); - - $response = wp_remote_get( $url, array( 'sslverify' => false ) ); - - if ( 200 == wp_remote_retrieve_response_code( $response ) ) { - return json_decode( wp_remote_retrieve_body( $response ) ); - } - - return false; - } - - /** - * Process the HTML returned from a Gist's JSON endpoint based on settings - * passed through the shortcode. - * - * @since 1.1.0 - * - * @param string $html HTML from the Gist's JSON endpoint. - * @param array $args List of shortcode attributes. - * @return string Modified HTML. - */ - public function process_gist_html( $html, $args ) { - // Remove the line number cell if it has been disabled. - if ( ! $args['show_line_numbers'] ) { - $html = preg_replace( '#.*?#s', '', $html ); - } - - // Remove the meta section if it has been disabled. - if ( ! $args['show_meta'] ) { - $html = preg_replace( '#
.*?
#s', '', $html ); - } - - $lines_pattern = '#(]+>)(.+?)#s'; - preg_match( $lines_pattern, $html, $lines_matches ); - - if( ! empty( $lines_matches[2] ) ) { - // Restrict the line number display if a range has been specified. - if ( $args['show_line_numbers'] && $args['lines']['min'] && $args['lines']['max'] ) { - $html = $this->limit_gist_line_numbers( $html, $args['lines'] ); - } - - if ( ! empty( $args['highlight'] ) ) { - // Flip to use isset() when looping through the lines. - $highlight = array_flip( $args['highlight'] ); - } - - // Extract and cleanup the individual lines from the Gist HTML into an array for processing. - $lines = trim( $lines_matches[2] ); - $lines = preg_split( '#[\s]*
#', substr( $lines, 5, strlen( $lines ) - 6 ) );
-			
-			foreach ( $lines as $key => $line ) {
-				// Remove lines if they're not in the specified range and continue.
-				if ( ( $args['lines']['min'] && $key < $args['lines']['min'] - 1 ) || ( $args['lines']['max'] && $key > $args['lines']['max'] - 1 ) ) {
-					unset( $lines[ $key ] );
-					continue;
-				}
-				
-				// Add classes for styling.
-				$classes = array( 'pre-line' );
-				$classes[] = ( $key % 2 ) ? 'pre-line-odd' : 'pre-line-even';
-				$style = '';
-				
-				if ( isset( $highlight[ $key + 1 ] ) ) {
-					$classes[] = 'pre-line-highlight';
-					
-					if ( ! empty( $args['highlight_color'] ) ) {
-						$style = ' style="background-color: ' . $args['highlight_color'] . ' !important"';
-					}
-				}
-				
-				$prepend = '
';
-				
-				$lines[ $key ] = $prepend . $line . '
'; - } - - $replacement = $lines_matches[1] . join( "\n", $lines ) . ''; - $html = preg_replace( $lines_pattern, $replacement, $html, 1 ); - } - - return $html; - } - - /** - * Removes line numbers from the Gist's HTML that fall outside the - * supplied range. - * - * @since 1.1.0 - * - * @param string $html HTML from the Gist's JSON endpoint. - * @param array $range Array of min and max values. - * @return string Modified HTML. - */ - public function limit_gist_line_numbers( $html, $range ) { - // Limit the line numbers that should show. - $line_num_pattern = '#()(.*?)#s'; - - preg_match( $line_num_pattern, $html, $line_num_matches ); - - if ( $line_num_matches[2] ) { - $line_numbers = array_slice( explode( "\n", trim( $line_num_matches[2] ) ), $range['min'] - 1, $range['max'] - $range['min'] + 1 ); - - $replacement = $line_num_matches[1] . join( "\n", $line_numbers ) . ''; - $html = preg_replace( $line_num_pattern, $replacement, $html, 1 ); - } - - return $html; - } - - /** - * Removes transients associated with Gists embedded in a post. - * - * Retrieves the keys of meta data associated with a post and deletes any - * transients with a matching embed key. - * - * @since 1.1.0 - * - * @param int $post_id Post ID. - * @param WP_Post $post_after Post object after update. - * @param WP_Post $post_before Post object before update. - */ - public function expire_gist_transients( $post_id, $post_after, $post_before ) { - $this->expire_transients = true; - - // Run the shorcodes to clear associated transients. - do_shortcode( $post_after->post_content ); - do_shortcode( $post_before->post_content ); - } - - /** - * Expire the transient associated with a particular shortcode so its HTML - * will be regenerated the next time it is requested. - * - * @since 1.1.0 - * - * @param array $args List of shortcode attributes. - */ - public function expire_gist_transient( $args ) { - $key = 'gist_embed_' . $this->shortcode_hash( 'gist', $args ); - set_transient( $key, null, -1 ); - } - - /** - * Get the debug log property. - * - * @since 1.1.0 - * - * @return array - */ - public function get_debug_log() { - return $this->debug_log; - } - - /** - * Simple debug logger. - * - * @since 1.1.0 - * - * @param mixed $value A value to log for the current shortcode. - */ - protected function debug_log( $value ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - $this->debug_log[ $this->debug_log_key ][] = $value; - } - } - - /** - * Sort a shortcode's attributes by name and hash it for use as a cache - * key. - * - * @since 1.1.0 - */ - protected function shortcode_hash( $tag, $args ) { - ksort( $args ); - return md5( $tag . '_' . serialize( $args ) ); - } - - /** - * Add a custom panel to the debug bar. - * - * @since 1.1.0 - * - * @param array $panels List of panels. - * @return array - */ - public function add_debug_bar_panel( $panels ) { - if ( ! class_exists( 'Blazer_Six_Gist_oEmbed_Debug_Bar_Panel' ) ) { - include( plugin_dir_path( __FILE__ ) . 'class-blazer-six-gist-oembed-debug-bar-panel.php' ); - $panels[] = new Blazer_Six_Gist_oEmbed_Debug_Bar_Panel(); - } - - return $panels; - } -} \ No newline at end of file + add_filter( 'debug_bar_panels', 'blazer_six_gist_oembed_add_debug_bar_panel' ); +} + +/** + * Add instance of our debug bar panel to Debug Bar plugin. + * + * @param array $panels + * + * @return Blazer_Six_Gist_oEmbed_Debug_Bar_Panel + */ +function blazer_six_gist_oembed_add_debug_bar_panel( array $panels ) { + global $gist_oembed_logger; +// wp_die('panel being added'); + if ( ! class_exists( 'Blazer_Six_Gist_oEmbed_Debug_Bar_Panel' ) ) { + require( plugin_dir_path( __FILE__ ) . 'class-blazer-six-gist-oembed-debug-bar-panel.php' ); + } + $panels[] = new Blazer_Six_Gist_oEmbed_Debug_Bar_Panel( $gist_oembed_logger ); + return $panels; +} diff --git a/class-blazer-six-gist-oembed-debug-bar-panel.php b/class-blazer-six-gist-oembed-debug-bar-panel.php index dee7b27..f230d2a 100644 --- a/class-blazer-six-gist-oembed-debug-bar-panel.php +++ b/class-blazer-six-gist-oembed-debug-bar-panel.php @@ -1,86 +1,84 @@ + * @author Gary Jones + * @copyright Copyright (c) 2012, Blazer Six, Inc. + * @license GPL-2.0+ + */ + /** * Class for displaying a custom panel on the debug bar with information about * Gist shortcodes used in a post. * - * @since 1.1.0 + * @package BlazerSix\GistoEmbed + * @author Brady Vercher + * @author Gary Jones */ class Blazer_Six_Gist_oEmbed_Debug_Bar_Panel extends Debug_Bar_Panel { + /** @var object Logger object. */ + protected $logger; + + /** + * Assign properties, and call parent constructor. + * + * @since 1.1.0 + * + * @param object $logger + */ + public function __construct( $logger ) { + $this->logger = $logger; + parent::__construct(); + } + /** * Initialize the panel and set its title. * * @since 1.1.0 */ - public function init(){ + public function init() { $this->title( __( 'Gist oEmbed', 'blazersix-gist-oembed' ) ); } /** - * Make the panel visibile. + * Make the panel visible, only if there is something to display. * * @since 1.1.0 */ public function prerender() { - $this->set_visible( true ); + $logs = $this->logger->get_logs(); + $this->set_visible( ! empty( $logs ) ); } /** - * Requests the debug log from the Gist oEmbed class and displays it in - * the custom debug bar panel. + * Request the log from the logger class and display it in the custom debug + * bar panel. * * @since 1.1.0 */ public function render() { - $gists = Blazer_Six_Gist_oEmbed::instance(); - - $log = $gists->get_debug_log(); - - foreach ( $log as $gist ) { + $logs = $this->logger->get_logs(); + foreach ( $logs as $log_id => $gist ) { echo '
'; - foreach ( $gist as $entry ) { - if ( is_scalar( $entry ) ) { - echo ( false === strpos( $entry, ' - - - - - - - - - $value ) : ?> - - - - - - -
- between line number spans. + echo ( false === strpos( $entry['message'], ''; } ?> + * @author Gary Jones + * @copyright Copyright (c) 2012, Blazer Six, Inc. + * @license GPL-2.0+ + */ + +/** + * Logging class. + * + * This class handles the recording of log messages - in particular, messages at + * the debug level. + * + * It follows a subset of PSR-3 and when WordPress bumps to PHP 5.3 as + * its minimum, then it can implement Psr\Log\LoggerInterface with minimal + * changes to the interface. Note however, that code in this file doesn't + * interact directly with WordPress at all. + * + * @see https://github.com/php-fig/log + * + * @package BlazerSix\GistoEmbed + * @author Gary Jones + * @author Brady Vercher + */ +class Blazer_Six_Gist_oEmbed_Log { + /** + * Holds the log messages and contextual data. + * + * The format is something like the following: + * + * $logs = array( + * 'fookey' => array( + * array( + * 'message' => 'Some message.', + * 'qwe' => 'rty'; + * ), + * array( + * 'message' => 'Another message for the same gist shortcode.', + * 'extra' => 'data'; + * 'if' => 'needed'; + * ), + * ), + * 'barkey' => array( + * array( + * 'message' => 'Some message for gist bar.', + * 'qwe' => 'rty'; + * ), + * array( + * 'message' => 'Another message for gist bar.', + * 'extra' => 'data'; + * 'if' => 'needed'; + * ), + * ), + * ); + */ + protected $logs = array(); + + /** + * Detailed debug information. + * + * @since 1.1.0 + * + * @uses Blazer_Six_Gist_oEmbed_Log::log() + * + * @param string $message + * @param array $context + */ + public function debug( $message, array $context = array() ) { + $this->log( 'debug', $message, $context ); + } + + /** + * Log with an arbitrary level. + * + * Include a 'key' key in the $context argument to group log messages + * together. If none is provided, the message is grouped by itself under an + * md5() hash of the message itself. + * + * Note that at this point in time, we don't actually do anything with the + * $level argument. + * + * @since 1.1.0 + * + * @param mixed $level + * @param string $message + * @param array $context + */ + public function log( $level, $message, array $context = array() ) { + $context['message'] = $message; + $key = isset( $context['key'] ) ? $context['key'] : md5( $message ); + unset( $context['key'] ); + $this->logs[$key][] = $context; + } + + /** + * Return recorded logs data. + * + * Under PSR-1, this method would be called getLogs(). + * + * @since 1.1.0 + * + * @return array + */ + public function get_logs() { + return $this->logs; + } +} \ No newline at end of file diff --git a/class-blazer-six-gist-oembed.php b/class-blazer-six-gist-oembed.php new file mode 100644 index 0000000..a19ba67 --- /dev/null +++ b/class-blazer-six-gist-oembed.php @@ -0,0 +1,579 @@ + + * @author Gary Jones + * @copyright Copyright (c) 2012, Blazer Six, Inc. + * @license GPL-2.0+ + * + * @todo Feed support: link directly to post, directly to Gist, or wrap in iframe? + * @todo Cache the style sheet locally. + */ + +/** + * The main plugin class. + * + * @package BlazerSix/GistoEmbed + * @author Brady Vercher + * @author Gary Jones + */ +class Blazer_Six_Gist_oEmbed { + /** @var object Logger object. */ + protected $logger = null; + + /** + * Toggle to short-circuit shortcode output and expire its corresponding + * transient so output can be regenerated the next time it is run. + * + * @var bool + */ + protected $expire_transients = false; + + /** + * Sets a logger instance on the object. + * + * Since logging is optional, the dependcy injection is done via this + * method, instead of being required through a constructor. + * + * Under PSR-1, this method would be called setLogger(). + * + * @see https://github.com/php-fig/log/blob/master/Psr/Log/LoggerAwareInterface.php + * + * @since 1.1.0 + * + * @param object $logger + */ + public function set_logger( $logger ) { + $this->logger = $logger; + } + + /** + * Return logger instance. + * + * Under PSR-1, this method would be called getLogger(). + * + * @since 1.1.0 + * + * @return object + */ + public function get_logger() { + return $this->$logger; + } + + /** + * Set up the plugin. + * + * Adds a [gist] shortcode to do the bulk of the heavy lifting. An embed + * handler is registered to mimic oEmbed functionality, but it relies on + * the shortcode for processing. + * + * Old URL Format: https://gist.github.com/{{id}}#file_{{filename}} + * New URL Format: https://gist.github.com/{{id}}#file-{{file_slug}} + * + * @since 1.1.0 + */ + public function run() { + // File matching is maintained for backward compatibility, but won't work for the new Gist "bookmark" URLs. + wp_embed_register_handler( 'gist', '#(https://gist\.github\.com/([a-z0-9]+))(?:\#file_(.*))?#i', array( $this, 'wp_embed_handler' ) ); + add_shortcode( 'gist', array( $this, 'shortcode' ) ); + + add_action( 'wp_enqueue_scripts', array( $this, 'style' ) ); + add_action( 'post_updated', array( $this, 'expire_gist_transients' ), 10, 3 ); + } + + /** + * Register the Gist style sheet so it can be embedded once. + * + * @since 1.0.0 + */ + public function style() { + wp_register_style( 'github-gist', get_option( 'blazersix_gist_embed_stylesheet' ) ); + } + + /** + * WP embed handler to generate a shortcode string from a Gist URL. + * + * Parses Gist URLs for oEmbed support. Returns the value as a shortcode + * string to let the shortcode method handle processing. The value + * returned also doesn't have wpautop() applied, which is a must for + * source code. + * + * @since 1.0.0 + */ + public function wp_embed_handler( $matches, $attr, $url, $rawattr ) { + $shortcode = '[gist'; + + if ( isset( $matches[2] ) && ! empty( $matches[2] ) ) { + $shortcode .= ' id="' . esc_attr( $matches[2] ) . '"'; + } + + // For backward compatibility. + if ( isset( $matches[3] ) && ! empty( $matches[3] ) ) { + $shortcode .= ' file="' . esc_attr( $matches[3] ) . '"'; + } + + // This attribute added so we can identify if a oembed URL or direct shortcode was used. + $shortcode .= ' oembed="1"]'; + + return $shortcode; + } + + /** + * Gist shortcode. + * + * Works with secret Gists, too. + * + * Shortcode attributes: + * + * - id - The Gist id (found in the URL). The only required attribute. + * - embed_stylesheet - Whether the external style sheet should be enqueued for output in the footer. + * * If the footer is too late, set to false and enqueue the 'github-gist' style before 'wp_head'. + * * Any custom styles should be added to the theme's style sheet. + * - file - Name of a specific file in a Gist. + * - highlight - Comma-separated list of line numbers to highlight. + * * Ranges can be specified. Ex: 2,4,6-10,12 + * - highlight_color - Background color of highlighted lines. + * * To change it globally, hook into the filter and supply a different color. + * - lines - A range of lines to limit the Gist to. + * * Suited for single file Gists or shortcodes using the 'file' attribute. + * - show_line_numbers - Whether line numbers should be displayed. + * - show_meta - Whether the trailing meta information in default Gist embeds should be displayed. + * + * @since 1.0.0 + * + * @param array $attr Attributes of the shortcode. + * + * @return string HTML content to display the Gist. + */ + public function shortcode( $attr ) { + global $post; + + // Rebuild the original shortcode as a string with raw attributes + $rawattr = array(); + foreach ( $attr as $key => $value ) { + if ( 'oembed' != $key ) { + $rawattr[] = $key . '="' . $value . '"'; + } + } + $shortcode = '[gist ' . implode(' ', $rawattr) . ']'; + + $defaults = apply_filters( + 'blazersix_gist_shortcode_defaults', + array( + 'embed_stylesheet' => apply_filters( 'blazersix_gist_embed_stylesheet_default', true ), + 'file' => '', + 'highlight' => array(), + 'highlight_color' => apply_filters( 'blazersix_gist_embed_highlight_color', '#ffffcc' ), + 'id' => '', + 'lines' => '', + 'show_line_numbers' => true, + 'show_meta' => true, + 'oembed' => 0, // private use only + ) + ); + + // Sanitize attributes. + $attr = shortcode_atts( $defaults, $attr ); + $attr['embed_stylesheet'] = $this->shortcode_bool( $attr['embed_stylesheet'] ); + $attr['show_line_numbers'] = $this->shortcode_bool( $attr['show_line_numbers'] ); + $attr['show_meta'] = $this->shortcode_bool( $attr['show_meta'] ); + $attr['highlight'] = $this->parse_highlight_arg( $attr['highlight'] ); + $attr['lines'] = $this->parse_line_number_arg( $attr['lines'] ); + + // Log what we're dealing with - title uses original attributes, but hashed against processed attributes. + $this->debug_log( '

' . $shortcode . '

', $this->shortcode_hash( 'gist', $attr ) ); + + // Short-circuit the shortcode output and just expire the transient. + // This is set to true when posts are updated. + if ( $this->expire_transients ) { + $this->expire_gist_transient( $attr ); + return; + } + + // Bail if the ID is not set. + if ( empty( $attr['id'] ) ) { + $this->debug_log( __( 'Shortcode did not have a required id attribute.', 'blazer_six_gist_oembed' ), $this->shortcode_hash( 'gist', $attr ) ); + return ''; + } + + $url = 'https://gist.github.com/' . $attr['id']; + $json_url = $url . '.json'; + + if ( isset( $post->ID ) ) { + $html = $this->get_gist_html( $json_url, $attr ); + + if ( '{{unknown}}' === $html ) { + return; + /** @todo Get reference to $wp_embed. Global? */ + return $wp_embed->maybe_make_link( $url ); + } + + // If there was a result, return it. + if ( $html ) { + if ( $attr['embed_stylesheet'] ) { + wp_enqueue_style( 'github-gist' ); + } + + $html = apply_filters( 'blazersix_gist_embed_html', $html, $url, $attr, $post->ID ); + + foreach ( $attr as $key => $value ) { + $message = '' . $key . __(' (shortcode attribute)', 'blazer_six_gist_oembed') . ': '; + $message .= is_scalar( $value ) ? $value : print_r( $value, true ); + $this->debug_log( $message, $this->shortcode_hash( 'gist', $attr ) ); + } + $this->debug_log( 'Gist:
' . $html, $this->shortcode_hash( 'gist', $attr ) ); + + return $html; + } + } + return ''; + } + + /** + * Helper method to determine if a shortcode attribute is true or false. + * + * @since 1.1.0 + * + * @param string|int|bool $var Attribute value. + * + * @return bool + */ + public function shortcode_bool( $var ) { + $falsey = array( 'false', '0', 'no', 'n' ); + return ( ! $var || in_array( strtolower( $var ), $falsey ) ) ? false : true; + } + + /** + * Parses and expands the shortcode 'highlight' attribute and returns it + * in a usable format. + * + * @since 1.1.0 + * + * @param string $line_numbers Comma-separated list of line numbers and ranges. + * + * @return array|null List of line numbers, or null if no line numbers given + */ + public function parse_highlight_arg( $line_numbers ) { + if ( empty( $line_numbers ) ) { + return null; + } + + // Determine which lines should be highlighted. + $highlight = array_map('trim', explode( ',', $line_numbers )); + + // Convert any ranges. + foreach ( $highlight as $index => $num ) { + if ( false !== strpos( $num, '-' ) ) { + unset( $highlight[ $index ] ); + + $range = array_map( 'trim', explode( '-', $num ) ); + foreach ( range( $range[0], $range[1] ) as $line ) { + array_push($highlight, $line); + } + } + } + return array_unique( $highlight ); + } + + /** + * Parses the shortcode 'lines' attribute into min and max values. + * + * @since 1.1.0 + * + * @param string $line_numbers Range of line numbers separated by a dash. + * + * @return array Array with min and max line numbers. + */ + public function parse_line_number_arg( $line_numbers ) { + if ( empty( $line_numbers ) ) { + return array( 'min' => 0, 'max' => 0, ); + } + + if ( false === strpos( $line_numbers, '-' ) ) { + $range = array_fill_keys( array( 'min', 'max', ), absint( trim( $line_numbers ) ) ); + } else { + $numbers = array_map( 'absint', array_map( 'trim', explode( '-', $line_numbers ) ) ); + + $range = array( + 'min' => $numbers[0], + 'max' => $numbers[1], + ); + } + + return $range; + } + + /** + * Retrieve Gist HTML. + * + * Gist HTML can come from one of three different sources: + * - Remote JSON endpoint. + * - Transient. + * - Post meta cache. + * + * When a Gist is intially requested, the HTML is fetched from the JSON + * endpoint and cached in a post meta field. It is then processed to limit + * line numbers, highlight specific lines, and add a few extra classes as + * style hooks. The processed HTML is then stored in a transient using a + * hash of the shortcodes attributes for the key. + * + * On subsequent requests, the HTML is fetched from the transient until it + * expires, then it is requested from the remote URL again. + * + * In the event the HTML can't be fetched from the remote endpoint and the + * transient is expired, the HTML is retrieved from the post meta backup. + * + * This algorithm allows Gist HTML to stay in sync with any changes GitHub + * may make to their markup, while providing a local cache for faster + * retrieval and a backup in case GitHub can't be reached. + * + * @since 1.1.0 + * + * @param string $url The JSON endpoint for the Gist. + * @param array $args List of shortcode attributes. + * + * @return string Gist HTML or {{unknown}} if it couldn't be determined. + */ + public function get_gist_html( $url, $args ) { + global $post; + + // Add a specific file from a Gist to the URL. + if ( ! empty( $args['file'] ) ) { + $url = add_query_arg( 'file', urlencode( $args['file'] ), $url ); + } + + $post_meta_key = '_gist_embed_' . md5( $url ); + $transient_key = 'gist_embed_' . $this->shortcode_hash( 'gist', $args ); + + $html = get_transient( $transient_key ); + + // Retrieve html from Gist JSON endpoint. + if ( empty( $html ) ) { + $this->debug_log( '' . __( 'Doing remote request:', 'blazersix-gist-oembed' ) . ' ' . $url, $this->shortcode_hash( 'gist', $args ) ); + $json = $this->fetch_gist( $url ); + + if ( ! empty( $json->div ) ) { + $html = $json->div; + } + + // Update the style sheet reference. + if ( ! empty( $json->stylesheet ) ) { + update_option( 'blazersix_gist_embed_stylesheet', $json->stylesheet ); + } + + // Failures are cached, too. Update the post to attempt to fetch again. + $html = ( $html ) ? $html : '{{unknown}}'; + $transient_expire = 60 * 60 * 24; + + if ( '{{unknown}}' != $html ) { + // Update the post meta fallback. + // @link http://core.trac.wordpress.org/ticket/21767 + update_post_meta( $post->ID, $post_meta_key, addslashes( $html ) ); + $html = $this->process_gist_html( $html, $args ); + $this->debug_log( '

' . __( 'Output Source: Remote Request', 'blazersix-gist-oembed' ) . '

', $this->shortcode_hash( 'gist', $args ) ); + } elseif ( $fallback = get_post_meta( $post->ID, $post_meta_key, true ) ) { + // Return the fallback instead of {{unknown}} + $html = $this->process_gist_html( $fallback, $args ); + + // Cache the fallback for an hour. + $transient_expire = 60 * 60; + $this->debug_log( '

' . __( 'Output Source: Post Meta Fallback', 'blazersix-gist-oembed' ) . '

', $this->shortcode_hash( 'gist', $args ) ); + } else { + $this->debug_log( '' . __( 'Remote call and transient failed and fallback was empty.', 'blazersix-gist-oembed' ) . '', $this->shortcode_hash( 'gist', $args ) ); + } + + // Cache the processed HTML. + set_transient( $transient_key, $html, $transient_expire ); + } else { + $this->debug_log( '

' . __( 'Output Source: Transient Cache', 'blazersix-gist-oembed' ) . '

', $this->shortcode_hash( 'gist', $args ) ); + } + + $this->debug_log( '' . __( 'JSON Endpoint:', 'blazersix-gist-oembed' ) . ' ' . $url, $this->shortcode_hash( 'gist', $args ) ); + $this->debug_log( '' . __( 'Post Meta Cache Key:', 'blazersix-gist-oembed' ) . ' ' . $post_meta_key, $this->shortcode_hash( 'gist', $args ) ); + $this->debug_log( '' . __( 'Transient Key:', 'blazersix-gist-oembed' ) . ' ' . $transient_key, $this->shortcode_hash( 'gist', $args ) ); + + return $html; + } + + /** + * Fetch Gist data from its JSON endpoint. + * + * @since 1.1.0 + * + * @param string $url Gist JSON endpoint. + * + * @return object|bool Gist JSON object or false. + */ + public function fetch_gist( $url ) { + $response = wp_remote_get( $url, array( 'sslverify' => false ) ); + + if ( 200 == wp_remote_retrieve_response_code( $response ) ) { + return json_decode( wp_remote_retrieve_body( $response ) ); + } + + return false; + } + + /** + * Process the HTML returned from a Gist's JSON endpoint based on settings + * passed through the shortcode. + * + * @since 1.1.0 + * + * @param string $html HTML from the Gist's JSON endpoint. + * @param array $args List of shortcode attributes. + * + * @return string Modified HTML. + */ + public function process_gist_html( $html, $args ) { + // Remove the line number cell if it has been disabled. + if ( ! $args['show_line_numbers'] ) { + $html = preg_replace( '#.*?#s', '', $html ); + } + + // Remove the meta section if it has been disabled. + if ( ! $args['show_meta'] ) { + $html = preg_replace( '#
.*?
#s', '', $html ); + } + + $lines_pattern = '#(]+>)(.+?)#s'; + preg_match( $lines_pattern, $html, $lines_matches ); + + if ( ! empty( $lines_matches[2] ) ) { + // Restrict the line number display if a range has been specified. + if ( $args['show_line_numbers'] && $args['lines']['min'] && $args['lines']['max'] ) { + $html = $this->limit_gist_line_numbers( $html, $args['lines'] ); + } + + if ( ! empty( $args['highlight'] ) ) { + // Flip to use isset() when looping through the lines. + $highlight = array_flip( $args['highlight'] ); + } + + // Extract and cleanup the individual lines from the Gist HTML into an array for processing. + $lines = trim( $lines_matches[2] ); + $lines = preg_split( '#
[\s]*
#', substr( $lines, 5, strlen( $lines ) - 6 ) );
+
+			foreach ( $lines as $key => $line ) {
+				// Remove lines if they're not in the specified range and continue.
+				if ( ( $args['lines']['min'] && $key < $args['lines']['min'] - 1 ) || ( $args['lines']['max'] && $key > $args['lines']['max'] - 1 ) ) {
+					unset( $lines[ $key ] );
+					continue;
+				}
+
+				// Add classes for styling.
+				$classes = array( 'pre-line' );
+				$classes[] = ( $key % 2 ) ? 'pre-line-odd' : 'pre-line-even';
+				$style = '';
+
+				if ( isset( $highlight[ $key + 1 ] ) ) {
+					$classes[] = 'pre-line-highlight';
+
+					if ( ! empty( $args['highlight_color'] ) ) {
+						$style = ' style="background-color: ' . $args['highlight_color'] . ' !important"';
+					}
+				}
+
+				$prepend = '
';
+
+				$lines[ $key ] = $prepend . $line . '
'; + } + + $replacement = $lines_matches[1] . join( "\n", $lines ) . ''; + $html = preg_replace( $lines_pattern, $replacement, $html, 1 ); + } + + return $html; + } + + /** + * Removes line numbers from the Gist's HTML that fall outside the + * supplied range. + * + * @since 1.1.0 + * + * @param string $html HTML from the Gist's JSON endpoint. + * @param array $range Array of min and max values. + * + * @return string Modified HTML. + */ + public function limit_gist_line_numbers( $html, $range ) { + // Limit the line numbers that should show. + $line_num_pattern = '#()(.*?)#s'; + + preg_match( $line_num_pattern, $html, $line_num_matches ); + + if ( $line_num_matches[2] ) { + $line_numbers = array_slice( explode( "\n", trim( $line_num_matches[2] ) ), $range['min'] - 1, $range['max'] - $range['min'] + 1 ); + + $replacement = $line_num_matches[1] . join( "\n", $line_numbers ) . ''; + $html = preg_replace( $line_num_pattern, $replacement, $html, 1 ); + } + + return $html; + } + + /** + * Removes transients associated with Gists embedded in a post. + * + * Retrieves the keys of meta data associated with a post and deletes any + * transients with a matching embed key. + * + * @since 1.1.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post_after Post object after update. + * @param WP_Post $post_before Post object before update. + */ + public function expire_gist_transients( $post_id, $post_after, $post_before ) { + $this->expire_transients = true; + + // Run the shortcodes to clear associated transients. + do_shortcode( $post_after->post_content ); + do_shortcode( $post_before->post_content ); + } + + /** + * Expire the transient associated with a particular shortcode so its HTML + * will be regenerated the next time it is requested. + * + * @since 1.1.0 + * + * @param array $args List of shortcode attributes. + */ + public function expire_gist_transient( $args ) { + $key = 'gist_embed_' . $this->shortcode_hash( 'gist', $args ); + set_transient( $key, null, -1 ); + } + + /** + * Wrapper for a PSR-3 compatible logger. + * + * If no logger has been set via the set_logger() method on an instance of + * this class, or WP_DEBUG is not enabled, then log messages quietly die + * here. + * + * @since 1.1.0 + * + * @param string $message A message to log for the current shortcode. + * @param mixed $id An ID under which the message should be grouped. + */ + protected function debug_log( $message, $id = null ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && isset( $this->logger ) ) { + $this->logger->debug( $message, array('key' => $id ) ); + } + } + + /** + * Sort a shortcode's attributes by name and hash it for use as a cache + * key and logger message grouping. + * + * @since 1.1.0 + */ + protected function shortcode_hash( $tag, $args ) { + ksort( $args ); + return md5( $tag . '_' . serialize( $args ) ); + } +}