WordPress.org

Making WordPress.org

Changeset 2638


Ignore:
Timestamp:
02/26/2016 02:26:25 AM (4 years ago)
Author:
dd32
Message:

Plugin Directory: Update the readme parser with a newer version of MarkdownExtra and support far more of our edgecases in readme's.
This also adds back Github readme.md formatting for most things (inline screenshots still not supported).
See #1584

Location:
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory
Files:
10 added
1 deleted
2 edited
1 copied

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-readme-parser.php

    r2602 r2638  
    11<?php
     2namespace WordPressdotorg\Plugin_Directory;
    23/**
    3  * Custom readme parser.
     4 * WordPress.org Plugin Readme Parser.
    45 *
    5  * Based on Automattic_Readme from http://code.google.com/p/wordpress-plugin-readme-parser/
     6 * Based on Baikonur_ReadmeParser from https://github.com/rmccue/WordPress-Readme-Parser
    67 *
    7  * Relies on Markdown_Extra
     8 * Relies on \Michaelf\Markdown_Extra
    89 *
    9  * @todo Handle screenshots section properly
    10  * @todo Create validator for this based on http://code.google.com/p/wordpress-plugin-readme-parser/source/browse/trunk/validator.php
     10 * @package WordPressdotorg\Plugin_Directory
    1111 */
    12 
    13 /**
    14  * Class Baikonur_ReadmeParser
    15  */
    16 class Baikonur_ReadmeParser {
    17     public static function parse_readme( $file ) {
     12class Readme_Parser {
     13    public $name              = '';
     14    public $tags              = array();
     15    public $requires          = '';
     16    public $tested            = '';
     17    public $contributors      = array();
     18    public $stable_tag        = '';
     19    public $donate_link       = '';
     20    public $short_description = '';
     21    public $sections          = array();
     22    public $upgrade_notice    = array();
     23    public $screenshots       = array();
     24
     25    // These are the readme sections which we expect
     26    private $expected_sections = array(
     27        'description',
     28        'installation',
     29        'faq',
     30        'screenshots',
     31        'changelog',
     32        'upgrade_notice',
     33        'other_notes',
     34    );
     35
     36    // We alias these sections, from => to
     37    private $alias_sections = array(
     38        'frequently_asked_questions' => 'faq',
     39        'change_log' => 'changelog',
     40        'screenshot' => 'screenshots',
     41    );
     42
     43    public function __construct( $file ) {
     44        $this->parse_readme( $file );
     45    }
     46
     47    protected function parse_readme( $file ) {
    1848        $contents = file( $file );
    1949
    20         return self::parse_readme_contents( $contents );
    21     }
    22 
    23     public static function parse_readme_contents( $contents ) {
    24         if ( is_string( $contents ) ) {
    25             $contents = explode( "\n", $contents );
    26         }
    27 
    28         $this_class = __CLASS__;
    29         if ( function_exists( 'get_called_class' ) ) {
    30             $this_class = get_called_class();
    31         }
    32 
    33         $contents = array_map( array( $this_class, 'strip_newlines' ), $contents );
     50        $contents = array_map( array( $this, 'strip_newlines' ), $contents );
    3451
    3552        // Strip BOM
     
    3855        }
    3956
    40         $data = new stdClass;
    41 
    42         // Defaults
    43         $data->is_excerpt        = false;
    44         $data->is_truncated      = false;
    45         $data->tags              = array();
    46         $data->requires          = '';
    47         $data->tested            = '';
    48         $data->contributors      = array();
    49         $data->stable_tag        = '';
    50         $data->version           = '';
    51         $data->donate_link       = '';
    52         $data->short_description = '';
    53         $data->sections          = array();
    54         $data->changelog         = array();
    55         $data->upgrade_notice    = array();
    56         $data->screenshots       = array();
    57         $data->remaining_content = array();
    58 
    59         $line       = call_user_func_array( array( $this_class, 'get_first_nonwhitespace' ), array( &$contents ) );
    60         $data->name = $line;
    61         $data->name = trim( $data->name, "#= " );
     57        $line       = $this->get_first_nonwhitespace( $contents );
     58        $this->name = $this->sanitize_text( trim( $line, "#= " ) );
     59
     60        // Strip Github style header\n==== underlines
     61        if ( '' === trim( $contents[0], '=-' ) ) {
     62            array_shift( $contents );
     63        }
    6264
    6365        // Parse headers
    6466        $headers = array();
    6567
    66         $line = call_user_func_array( array( $this_class, 'get_first_nonwhitespace' ), array( &$contents ) );
     68        $line = $this->get_first_nonwhitespace( $contents );
    6769        do {
    6870            $key = $value = null;
     
    7274            $bits = explode( ':', $line, 2 );
    7375            list( $key, $value ) = $bits;
    74             $key = strtolower( str_replace( array( ' ', "\t" ), '_', trim( $key ) ) );
    75             if ( $key === 'tags' && isset( $headers['tags'] ) ) {
    76                 $headers[ $key ] .= ',' . trim( $value );
    77             } else {
    78                 $headers[ $key ] = trim( $value );
    79             }
     76            $key = strtolower( str_replace( ' ', '_', trim( $key, " \t*-" ) ) );
     77            $headers[ $key ] = trim( $value );
    8078        } while ( ( $line = array_shift( $contents ) ) !== null && ( $line = trim( $line ) ) && ! empty( $line ) );
    8179        array_unshift( $contents, $line );
    8280
    8381        if ( ! empty( $headers['tags'] ) ) {
    84             $data->tags = explode( ',', $headers['tags'] );
    85             $data->tags = array_map( 'trim', $data->tags );
    86         }
    87         if ( ! empty( $headers['requires'] ) ) {
    88             $data->requires = $headers['requires'];
     82            $this->tags = explode( ',', $headers['tags'] );
     83            $this->tags = array_map( 'trim', $this->tags );
    8984        }
    9085        if ( ! empty( $headers['requires_at_least'] ) ) {
    91             $data->requires = $headers['requires_at_least'];
    92         }
    93         if ( ! empty( $headers['tested'] ) ) {
    94             $data->tested = $headers['tested'];
     86            $this->requires = $headers['requires_at_least'];
     87        } elseif ( ! empty( $headers['requires'] ) ) {
     88            $this->requires = $headers['requires'];
    9589        }
    9690        if ( ! empty( $headers['tested_up_to'] ) ) {
    97             $data->tested = $headers['tested_up_to'];
     91            $this->tested = $headers['tested_up_to'];
     92        } elseif ( ! empty( $headers['tested'] ) ) {
     93            $this->tested = $headers['tested'];
    9894        }
    9995        if ( ! empty( $headers['contributors'] ) ) {
    100             $data->contributors = explode( ',', $headers['contributors'] );
    101             $data->contributors = array_map( 'trim', $data->contributors );
     96            $this->contributors = explode( ',', $headers['contributors'] );
     97            $this->contributors = array_map( 'trim', $this->contributors );
     98            $this->contributors = $this->sanitize_contributors( $this->contributors );
    10299        }
    103100        if ( ! empty( $headers['stable_tag'] ) ) {
    104             $data->stable_tag = $headers['stable_tag'];
     101            $this->stable_tag = $headers['stable_tag'];
    105102        }
    106103        if ( ! empty( $headers['donate_link'] ) ) {
    107             $data->donate_link = $headers['donate_link'];
    108         }
    109         if ( ! empty( $headers['version'] ) ) {
    110             $data->version = $headers['version'];
    111         } else {
    112             $data->version = $data->stable_tag;
     104            $this->donate_link = $headers['donate_link'];
    113105        }
    114106
     
    117109            $trimmed = trim( $line );
    118110            if ( empty( $trimmed ) ) {
    119                 $data->short_description .= "\n";
     111                $this->short_description .= "\n";
    120112                continue;
    121113            }
    122             if ( $trimmed[0] === '=' && isset( $trimmed[1] ) && $trimmed[1] === '=' ) {
     114            if ( ( '=' === $trimmed[0] && isset( $trimmed[1] ) && '=' === $trimmed[1] ) ||
     115                 ( '#' === $trimmed[0] && isset( $trimmed[1] ) && '#' === $trimmed[1] ) ) { // Stop after any Markdown heading
    123116                array_unshift( $contents, $line );
    124117                break;
    125118            }
    126119
    127             $data->short_description .= $line . "\n";
    128         }
    129         $data->short_description = trim( $data->short_description );
    130         if ( ! $data->short_description && ! empty( $headers['description'] ) ) {
    131             $data->short_description = $headers['description'];
    132         }
    133 
    134         $data->is_truncated = call_user_func_array( array(
    135             $this_class,
    136             'trim_short_desc'
    137         ), array( &$data->short_description ) );
     120            $this->short_description .= $line . "\n";
     121        }
     122        $this->short_description = trim( $this->short_description );
    138123
    139124        // Parse the rest of the body
    140         $current = '';
    141         $special = array(
    142             'description',
    143             'installation',
    144             'faq',
    145             'frequently_asked_questions',
    146             'screenshots',
    147             'changelog',
    148             'upgrade_notice'
    149         );
    150 
     125        // Prefill the sections, we'll filter out empty sections later.
     126        $this->sections = array_fill_keys( $this->expected_sections, '' );
     127        $current = $section_name = $section_title = '';
    151128        while ( ( $line = array_shift( $contents ) ) !== null ) {
    152129            $trimmed = trim( $line );
     
    156133            }
    157134
    158             if ( $trimmed[0] === '=' && isset( $trimmed[1] ) && $trimmed[1] === '=' ) {
    159                 if ( ! empty( $title ) ) {
    160                     $data->sections[ $title ] = trim( $current );
    161                 }
    162 
    163                 $current    = '';
    164                 $real_title = trim( $line, "#= \t" );
    165                 $title      = strtolower( str_replace( ' ', '_', $real_title ) );
    166                 if ( $title === 'faq' ) {
    167                     $title = 'frequently_asked_questions';
    168                 } elseif ( $title === 'change_log' ) {
    169                     $title = 'changelog';
    170                 }
    171                 if ( ! in_array( $title, $special ) ) {
    172                     $current .= '<h3>' . $real_title . "</h3>";
     135            if ( ( '=' === $trimmed[0] && isset( $trimmed[1] ) && '=' === $trimmed[1] ) ||
     136                 ( '#' === $trimmed[0] && isset( $trimmed[1] ) && '#' === $trimmed[1] && isset( $trimmed[2] ) && '#' !== $trimmed[2] ) ) { // Stop only after a ## Markdown header, not a ###.
     137                if ( ! empty( $section_name ) ) {
     138                    $this->sections[ $section_name ] .= trim( $current );
     139                }
     140
     141                $current       = '';
     142                $section_title = trim( $line, "#= \t" );
     143                $section_name  = strtolower( str_replace( ' ', '_', $section_title ) );
     144
     145                if ( isset( $this->alias_sections[ $section_name ] ) ) {
     146                    $section_name = $this->alias_sections[ $section_name ];
     147                }
     148
     149                // If we encounter an unknown section header, include the provided Title, we'll filter it to other_notes later.
     150                if ( ! in_array( $section_name, $this->expected_sections ) ) {
     151                    $current .= '<h3>' . $section_title . '</h3>';
     152                    $section_name = 'other_notes';
    173153                }
    174154                continue;
     
    178158        }
    179159
    180         if ( ! empty( $title ) ) {
    181             $data->sections[ $title ] = trim( $current );
    182         }
    183         $title   = null;
    184         $current = null;
    185 
    186         if ( empty( $data->sections['description'] ) ) {
    187             $data->sections['description'] = call_user_func( array(
    188                 $this_class,
    189                 'parse_markdown'
    190             ), $data->short_description );
    191         }
    192 
    193         // Parse changelog
    194         if ( ! empty( $data->sections['changelog'] ) ) {
    195             $lines = explode( "\n", $data->sections['changelog'] );
     160        if ( ! empty( $section_name ) ) {
     161            $this->sections[ $section_name ] .= trim( $current );
     162        }
     163
     164        // Filter out any empty sections.
     165        $this->sections = array_filter( $this->sections );
     166
     167        // Use the description for the short description if not provided.
     168        if ( empty( $this->short_description ) && ! empty( $this->sections['description'] ) ) {
     169            $this->short_description = $this->sections['description'];
     170        }
     171
     172        // Use the short description for the description section if not provided.
     173        if ( empty( $this->sections['description'] ) ) {
     174            $this->sections['description'] = $this->short_description;
     175        }
     176
     177        // Sanitize and trim the short_description to match requirements
     178        $this->short_description = $this->sanitize_text( $this->short_description );
     179        $this->short_description = $this->trim_length( $this->short_description, 150 );
     180
     181        // Parse out the Upgrade Notice section into it's own data
     182        if ( isset( $this->sections['upgrade_notice'] ) ) {
     183            $lines = explode( "\n", $this->sections['upgrade_notice'] );
     184            $version = null;
    196185            while ( ( $line = array_shift( $lines ) ) !== null ) {
    197186                $trimmed = trim( $line );
     
    200189                }
    201190
    202                 if ( $trimmed[0] === '=' ) {
     191                if ( '=' === $trimmed[0] || '#' === $trimmed[0] ) {
    203192                    if ( ! empty( $current ) ) {
    204                         $data->changelog[ $title ] = trim( $current );
     193                        $this->upgrade_notice[ $version ] = $this->sanitize_text( trim( $current ) );
    205194                    }
    206195
    207196                    $current = '';
    208                     $title  = trim( $line, "#= \t" );
     197                    $version = trim( $line, "#= \t" );
    209198                    continue;
    210199                }
     
    212201                $current .= $line . "\n";
    213202            }
    214 
    215             $data->changelog[ $title ] = trim( $current );
    216         }
    217         $title   = null;
    218         $current = null;
    219 
    220         if ( isset( $data->sections['upgrade_notice'] ) ) {
    221             $lines = explode( "\n", $data->sections['upgrade_notice'] );
    222             while ( ( $line = array_shift( $lines ) ) !== null ) {
    223                 $trimmed = trim( $line );
    224                 if ( empty( $trimmed ) ) {
    225                     continue;
    226                 }
    227 
    228                 if ( $trimmed[0] === '=' ) {
    229                     if ( ! empty( $current ) ) {
    230                         $data->upgrade_notice[ $title ] = trim( $current );
    231                     }
    232 
    233                     $current = '';
    234                     $title   = trim( $line, "#= \t" );
    235                     continue;
    236                 }
    237 
    238                 $current .= $line . "\n";
    239             }
    240 
    241             if ( ! empty( $title ) && ! empty( $current ) ) {
    242                 $data->upgrade_notice[ $title ] = trim( $current );
    243             }
    244             unset( $data->sections['upgrade_notice'] );
     203            if ( ! empty( $version ) && ! empty( $current ) ) {
     204                $this->upgrade_notice[ $version ] = $this->sanitize_text( trim( $current ) );
     205            }
     206            unset( $this->sections['upgrade_notice'] );
    245207        }
    246208
    247209        // Markdownify!
    248         $data->sections       = array_map( array( $this_class, 'parse_markdown' ), $data->sections );
    249         $data->changelog      = array_map( array( $this_class, 'parse_markdown' ), $data->changelog );
    250         $data->upgrade_notice = array_map( array( $this_class, 'parse_markdown' ), $data->upgrade_notice );
    251 
    252         if ( isset( $data->sections['screenshots'] ) ) {
    253             preg_match_all( '#<li>(.*?)</li>#is', $data->sections['screenshots'], $screenshots, PREG_SET_ORDER );
     210        $this->sections       = array_map( array( $this, 'parse_markdown' ), $this->sections );
     211        $this->upgrade_notice = array_map( array( $this, 'parse_markdown' ), $this->upgrade_notice );
     212
     213        if ( isset( $this->sections['screenshots'] ) ) {
     214            preg_match_all( '#<li>(.*?)</li>#is', $this->sections['screenshots'], $screenshots, PREG_SET_ORDER );
    254215            if ( $screenshots ) {
    255                 foreach ( (array) $screenshots as $ss ) {
    256                     $data->screenshots[] = trim( $ss[1] );
    257                 }
    258             }
    259         }
    260 
    261         // Rearrange stuff.
    262         $data->remaining_content = $data->sections;
    263         $data->sections          = array();
    264 
    265         foreach ( $special as $spec ) {
    266             if ( isset( $data->remaining_content[ $spec ] ) ) {
    267                 $data->sections[ $spec ] = $data->remaining_content[ $spec ];
    268                 unset( $data->remaining_content[ $spec ] );
    269             }
    270         }
    271 
    272         return $data;
    273     }
    274 
    275     protected static function get_first_nonwhitespace( &$contents ) {
     216                $i = 1; // Screenshots start from 1
     217                foreach ( $screenshots as $ss ) {
     218                    $this->screenshots[ $i++ ] = $this->filter_text( $ss[1] );
     219                }
     220            }
     221            unset( $this->sections['screenshots'] );
     222        }
     223
     224        // Filter the HTML
     225        $this->sections = array_map( array( $this, 'filter_text' ), $this->sections );
     226
     227        return true;
     228    }
     229
     230    protected function get_first_nonwhitespace( &$contents ) {
    276231        while ( ( $line = array_shift( $contents ) ) !== null ) {
    277232            $trimmed = trim( $line );
     
    284239    }
    285240
    286     protected static function strip_newlines( $line ) {
     241    protected function strip_newlines( $line ) {
    287242        return rtrim( $line, "\r\n" );
    288243    }
    289244
    290     protected static function trim_short_desc( &$desc ) {
     245    protected function trim_length( $desc, $length = 150 ) {
    291246        if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
    292             if ( mb_strlen( $desc ) > 150 ) {
    293                 $desc = mb_substr( $desc, 0, 150 );
    294                 $desc = trim( $desc );
    295 
    296                 return true;
     247            if ( mb_strlen( $desc ) > $length ) {
     248                $desc = mb_substr( $desc, 0, $length );
    297249            }
    298250        } else {
    299             if ( strlen( $desc ) > 150 ) {
    300                 $desc = substr( $desc, 0, 150 );
    301                 $desc = trim( $desc );
    302 
    303                 return true;
    304             }
    305         }
    306 
    307         return false;
    308     }
    309 
    310     protected static function parse_markdown( $text ) {
    311         $text = self::code_trick( $text );
     251            if ( strlen( $desc ) > $length ) {
     252                $desc = substr( $desc, 0, $length );
     253            }
     254        }
     255
     256        return trim( $desc );
     257    }
     258
     259    /**
     260     * @access protected
     261     *
     262     * @param string $text
     263     * @return string
     264     */
     265    protected function filter_text( $text ) {
     266        $text = trim( $text );
     267
     268        $allowed = array(
     269            'a'          => array(
     270                'href'  => true,
     271                'title' => true,
     272                'rel'   => true,
     273            ),
     274            'blockquote' => array(
     275                'cite' => true
     276            ),
     277            'br'         => true,
     278            'p'          => true,
     279            'code'       => true,
     280            'pre'        => true,
     281            'em'         => true,
     282            'strong'     => true,
     283            'ul'         => true,
     284            'ol'         => true,
     285            'li'         => true,
     286            'h3'         => true,
     287            'h4'         => true,
     288        );
     289
     290        $text = balanceTags( $text );
     291        $text = make_clickable( $text );
     292
     293        $text = wp_kses( $text, $allowed );
     294
     295        // wpautop() will eventually replace all \n's with <br>s, and that isn't what we want.
     296        $text = preg_replace( "/(?<![> ])\n/", ' ', $text );
     297
     298        $text = trim( $text );
     299
     300        return $text;
     301    }
     302
     303    /**
     304     * @access protected
     305     *
     306     * @param string $text
     307     * @return string
     308     */
     309    protected function sanitize_text( $text ) { // not fancy
     310        $text = strip_tags( $text );
     311        $text = esc_html( $text );
     312        $text = trim( $text );
     313
     314        return $text;
     315    }
     316
     317    /**
     318     * Sanitize proided contributors to valid WordPress users
     319     *
     320     * @param array $users Array of user_login's or user_nicename's.
     321     * @return array Array of user_logins.
     322     */
     323    protected function sanitize_contributors( $users ) {
     324        foreach ( $users as $i => $name ) {
     325            if ( get_user_by( 'login', $name ) ) {
     326                continue;
     327            } elseif ( false !== ( $user = get_user_by( 'slug', $name ) ) ) {
     328                // Overwrite the nicename with the user_login
     329                $users[ $i ] = $user->user_login;
     330            } else {
     331                // Unknown user, we'll skip these entirely to encourage correct readmes
     332                unset( $users[ $i ] );
     333            }
     334        }
     335        return $users;
     336    }
     337
     338    protected function parse_markdown( $text ) {
     339        static $markdown = null;
     340        if ( ! class_exists( '\\Michelf\\MarkdownExtra' ) ) {
     341            // TODO: Autoloader?
     342            include __DIR__ . '/libs/michelf-php-markdown-1.6.0/Michelf/MarkdownExtra.inc.php';
     343        }
     344        if ( is_null( $markdown ) ) {
     345            $markdown = new \Michelf\MarkdownExtra();
     346        }
     347
     348        $text = $this->code_trick( $text );
    312349        $text = preg_replace( '/^[\s]*=[\s]+(.+?)[\s]+=/m', "\n" . '<h4>$1</h4>' . "\n", $text );
    313         $text = Markdown( trim( $text ) );
     350        $text = $markdown->transform( trim( $text ) );
    314351
    315352        return trim( $text );
    316353    }
    317354
    318     protected static function code_trick( $text ) {
     355    protected function code_trick( $text ) {
    319356        // If doing markdown, first take any user formatted code blocks and turn them into backticks so that
    320357        // markdown will preserve things like underscores in code blocks
    321         $text = preg_replace_callback( "!(<pre><code>|<code>)(.*?)(</code></pre>|</code>)!s", array( __CLASS__, 'decodeit' ), $text );
     358        $text = preg_replace_callback( "!(<pre><code>|<code>)(.*?)(</code></pre>|</code>)!s", array( $this, 'code_trick_decodeit_cb' ), $text );
    322359        $text = str_replace( array( "\r\n", "\r" ), "\n", $text );
    323360
    324361        // Markdown can do inline code, we convert bbPress style block level code to Markdown style
    325         $text = preg_replace_callback( "!(^|\n)([ \t]*?)`(.*?)`!s", array( __CLASS__, 'indent' ), $text );
     362        $text = preg_replace_callback( "!(^|\n)([ \t]*?)`(.*?)`!s", array( $this, 'code_trick_indent_cb' ), $text );
    326363
    327364        return $text;
    328365    }
    329366
    330     protected static function indent( $matches ) {
     367    protected function code_trick_indent_cb( $matches ) {
    331368        $text = $matches[3];
    332369        $text = preg_replace( '|^|m', $matches[2] . '    ', $text );
     
    335372    }
    336373
    337     protected static function decodeit( $matches ) {
     374    protected function code_trick_decodeit_cb( $matches ) {
    338375        $text        = $matches[2];
    339376        $trans_table = array_flip( get_html_translation_table( HTML_ENTITIES ) );
     
    343380        $text        = str_replace( '&#39;', "'", $text );
    344381
    345             if ( '<pre><code>' == $matches[1] ) {
     382        if ( '<pre><code>' == $matches[1] ) {
    346383            $text = "\n$text\n";
    347384        }
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-tools.php

    r2621 r2638  
    88 */
    99class Tools {
    10 
    11     /**
    12      * @param string $readme
    13      * @return object
    14      */
    15     static function get_readme_data( $readme ) {
    16 
    17         // Uses https://github.com/rmccue/WordPress-Readme-Parser (with modifications)
    18         include_once __DIR__ . '/readme-parser/markdown.php';
    19         include_once __DIR__ . '/readme-parser/compat.php';
    20 
    21         $data = (object) \WPorg_Readme::parse_readme( $readme );
    22 
    23         unset( $data->sections['screenshots'] ); // Useless.
    24 
    25         // Sanitize contributors.
    26         foreach ( $data->contributors as $i => $name ) {
    27             if ( get_user_by( 'login', $name ) ) {
    28                 continue;
    29             } elseif ( false !== ( $user = get_user_by( 'slug', $name ) ) ) {
    30                 $data->contributors[] = $user->user_login;
    31                 unset( $data->contributors[ $i ] );
    32             } else {
    33                 unset( $data->contributors[ $i ] );
    34             }
    35         }
    36 
    37         return $data;
    38     }
    3910
    4011    /**
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/libs/CREDITS

    r2555 r2638  
    11GeoPattern-php - MIT Licensed, https://github.com/redeyeventures/geopattern-php
     2michelf-php-markdown - BSD-3-Clause, https://github.com/michelf/php-markdown
Note: See TracChangeset for help on using the changeset viewer.