Add support for updating plugins hosted on GitHub.

- Added experimental GitHub support. The new PucGitHubChecker subclass can check a GitHub repository for plugin updates. Depending on configuration, it will use either the latest release, the latest tag, or the specified branch. It can also automagically extract version details (description, changelog, etc) from a number of different locations - release names, plugin headers, readme.txt, changelog.md and more.
- The "slug" field of the metadata file is no longer used. The update checker will now use the slug passed to the class constructor, or generate a slug based on the plugin file name.
- Other minor changes to slug handling.
- Version bump to 2.0.
This commit is contained in:
Yahnis Elsts 2015-02-10 11:46:46 +02:00
parent 037ab7d2a4
commit 03dd38fd71
5 changed files with 2457 additions and 42 deletions

View File

@ -5,4 +5,90 @@ This is a custom update checker library for WordPress plugins. It lets you add a
From the users' perspective, it works just like with plugins hosted on WordPress.org. The update checker uses the default plugin upgrade UI that will already be familiar to most WordPress users.
[See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information and usage instructions.
[See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information and usage instructions.
Getting Started
---------------
### Self-hosted Plugins
1. Make a JSON file that describes your plugin. Here's a minimal example:
```json
{
"name" : "My Cool Plugin",
"version" : "2.0",
"author" : "John Smith",
"download_url" : "http://example.com/plugins/my-cool-plugin.zip",
"sections" : {
"description" : "Plugin description here. You can use HTML."
}
}
```
See [this table](https://spreadsheets.google.com/pub?key=0AqP80E74YcUWdEdETXZLcXhjd2w0cHMwX2U1eDlWTHc&authkey=CK7h9toK&hl=en&single=true&gid=0&output=html) for a full list of supported fields.
2. Upload this file to a publicly accessible location.
3. Download [the update checker](https://github.com/YahnisElsts/plugin-update-checker/releases/latest), unzip the archive and copy the `plugin-update-checker` directory to your plugin.
4. Add the following code to the main plugin file:
```php
require 'plugin-update-checker/plugin-update-checker.php';
$myUpdateChecker = PucFactory::buildUpdateChecker(
'http://example.com/path/to/metadata.json',
__FILE__
);
```
#### Notes
- You could use [wp-update-server](https://github.com/YahnisElsts/wp-update-server) to automatically generate JSON metadata from ZIP packages.
- The second argument passed to `buildUpdateChecker` should be the full path to the main plugin file.
- There are more options available - see the [blog](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for details.
### Plugins Hosted on GitHub
*(GitHub support is experimental.)*
1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest), unzip it and copy the `plugin-update-checker` directory to your plugin.
2. Add the following code to the main file of your plugin:
```php
require 'plugin-update-checker/plugin-update-checker.php';
$className = PucFactory::getLatestClassVersion('PucGitHubChecker');
$myUpdateChecker = new $className(
'https://github.com/user-name/plugin-repo-name/',
__FILE__,
'master'
);
```
The third argument specifies the branch to use for updating your plugin. The default is `master`. If the branch name is omitted or set to `master`, the update checker will use the latest release or tag (if available). Otherwise it will use the specified branch.
3. Optional: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt). The contents of this file will be shown when the user clicks the "View version 1.2.3 details" link.
#### Notes
If your GitHub repository requires an access token, you can specify it like this:
```php
$myUpdateChecker->setAccessToken('your-token-here');
```
The GitHub version of the library will pull update details from the following parts of a release/tag/branch:
- Changelog
- The "Changelog" section of `readme.txt`.
- One of the following files:
CHANGES.md, CHANGELOG.md, changes.md, changelog.md
- Release notes.
- Version number
- The "Version" plugin header.
- The latest release or tag name.
- Required and tested WordPress versions
- The "Requires at least" and "Tested up to" fields in `readme.txt`.
- The following plugin headers:
`Required WP`, `Tested WP`, `Requires at least`, `Tested up to`
- "Last updated" timestamp
- The creation timestamp of the latest release.
- The latest commit of the selected tag or branch that changed the main plugin file.
- Number of downloads
- The `download_count` statistic of the latest release.
- If you're not using GitHub releases, there will be no download stats.
- Other plugin details - author, homepage URL, description
- The "Description" section of `readme.txt`.
- Remote plugin headers (i.e. the latest version on GitHub).
- Local plugin headers (i.e. the currently installed version).
- Ratings, banners, screenshots
- Not supported.

449
github-checker.php Normal file
View File

@ -0,0 +1,449 @@
<?php
if ( !class_exists('PucGitHubChecker_2_0') ):
class PucGitHubChecker_2_0 extends PluginUpdateChecker_2_0 {
/**
* @var string GitHub username.
*/
protected $userName;
/**
* @var string GitHub repository name.
*/
protected $repositoryName;
/**
* @var string Either a fully qualified repository URL, or just "user/repo-name".
*/
protected $repositoryUrl;
/**
* @var string The branch to use as the latest version. Defaults to "master".
*/
protected $branch;
/**
* @var string GitHub authentication token. Optional.
*/
protected $accessToken;
public function __construct(
$repositoryUrl,
$pluginFile,
$branch = 'master',
$checkPeriod = 12,
$optionName = '',
$muPluginFile = ''
) {
$this->repositoryUrl = $repositoryUrl;
$this->branch = empty($branch) ? 'master' : $branch;
$path = @parse_url($repositoryUrl, PHP_URL_PATH);
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
$this->userName = $matches['username'];
$this->repositoryName = $matches['repository'];
} else {
throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"');
}
parent::__construct($repositoryUrl, $pluginFile, '', $checkPeriod, $optionName, $muPluginFile);
}
/**
* Retrieve details about the latest plugin version from GitHub.
*
* @param array $unusedQueryArgs Unused.
* @return PluginInfo
*/
public function requestInfo($unusedQueryArgs = array()) {
$info = new PluginInfo_2_0();
$info->filename = $this->pluginFile;
$info->slug = $this->slug;
$info->sections = array();
$this->setInfoFromHeader($this->getPluginHeader(), $info);
//Figure out which reference (tag or branch) we'll use to get the latest version of the plugin.
$ref = $this->branch;
if ( $this->branch === 'master' ) {
//Use the latest release.
$release = $this->getLatestRelease();
if ( $release !== null ) {
$ref = $release->tag_name;
$info->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
$info->last_updated = $release->created_at;
$info->download_url = $release->zipball_url;
if ( !empty($release->body) ) {
$info->sections['changelog'] = $this->parseMarkdown($release->body);
}
if ( isset($release->assets[0]) ) {
$info->downloaded = $release->assets[0]->download_count;
}
} else {
//Failing that, use the tag with the highest version number.
$tag = $this->getLatestTag();
if ( $tag !== null ) {
$ref = $tag->name;
$info->version = $tag->name;
$info->download_url = $tag->zipball_url;
}
}
}
if ( empty($info->download_url) ) {
$info->download_url = $this->buildArchiveDownloadUrl($ref);
}
//Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata
//are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags.
$mainPluginFile = basename($this->pluginFile);
$remotePlugin = $this->getRemoteFile($mainPluginFile, $ref);
if ( !empty($remotePlugin) ) {
$remoteHeader = $this->getFileHeader($remotePlugin);
$this->setInfoFromHeader($remoteHeader, $info);
}
//Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain
//a lot of useful information like the required/tested WP version, changelog, and so on.
if ( $this->readmeTxtExistsLocally() ) {
$readmeTxt = $this->getRemoteFile('readme.txt', $ref);
if ( !empty($readmeTxt) ) {
$readme = $this->parseReadme($readmeTxt);
if ( isset($readme['sections']) ) {
$info->sections = array_merge($info->sections, $readme['sections']);
}
if ( !empty($readme['tested_up_to']) ) {
$info->tested = $readme['tested_up_to'];
}
if ( !empty($readme['requires_at_least']) ) {
$info->requires = $readme['requires_at_least'];
}
if ( isset($readme['upgrade_notice'], $readme['upgrade_notice'][$info->version]) ) {
$info->upgrade_notice = $readme['upgrade_notice'][$info->version];
}
}
}
//The changelog might be in a separate file.
if ( empty($info->sections['changelog']) ) {
$info->sections['changelog'] = $this->getRemoteChangelog($ref);
if ( empty($info->sections['changelog']) ) {
$info->sections['changelog'] = 'There is no changelog available.';
}
}
if ( empty($info->last_updated) ) {
//Fetch the latest commit that changed the main plugin file and use it as the "last_updated" date.
//It's reasonable to assume that every update will change the version number in that file.
$latestCommit = $this->getLatestCommit($mainPluginFile, $ref);
if ( $latestCommit !== null ) {
$info->last_updated = $latestCommit->commit->author->date;
}
}
$info = apply_filters('puc_request_info_result-' . $this->slug, $info, null);
return $info;
}
/**
* Get the latest release from GitHub.
*
* @return StdClass|null
*/
protected function getLatestRelease() {
$releases = $this->api('/repos/:user/:repo/releases');
if ( is_wp_error($releases) || !is_array($releases) || !isset($releases[0]) ) {
return null;
}
$latestRelease = $releases[0];
return $latestRelease;
}
/**
* Get the tag that looks like the highest version number.
*
* @return StdClass|null
*/
protected function getLatestTag() {
$tags = $this->api('/repos/:user/:repo/tags');
if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) {
return null;
}
usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest.
return $tags[0];
}
/**
* Compare two GitHub tags as if they were version number.
*
* @param string $tag1
* @param string $tag2
* @return int
*/
protected function compareTagNames($tag1, $tag2) {
if ( !isset($tag1->name) ) {
return 1;
}
if ( !isset($tag2->name) ) {
return -1;
}
return -version_compare($tag1->name, $tag2->name);
}
/**
* Get the latest commit that changed the specified file.
*
* @param string $filename
* @param string $ref Reference name (e.g. branch or tag).
* @return StdClass|null
*/
protected function getLatestCommit($filename, $ref = 'master') {
$commits = $this->api(
'/repos/:user/:repo/commits',
array(
'path' => $filename,
'sha' => $ref,
)
);
if ( !is_wp_error($commits) && is_array($commits) && isset($commits[0]) ) {
return $commits[0];
}
return null;
}
protected function getRemoteChangelog($ref = '') {
$filename = $this->getChangelogFilename();
if ( empty($filename) ) {
return null;
}
$changelog = $this->getRemoteFile($filename, $ref);
if ( $changelog === null ) {
return null;
}
return $this->parseMarkdown($changelog);
}
protected function getChangelogFilename() {
$pluginDirectory = dirname($this->pluginAbsolutePath);
if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) {
return null;
}
$possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md');
$files = scandir($pluginDirectory);
$foundNames = array_intersect($possibleNames, $files);
if ( !empty($foundNames) ) {
return reset($foundNames);
}
return null;
}
/**
* Convert Markdown to HTML.
*
* @param string $markdown
* @return string
*/
protected function parseMarkdown($markdown) {
if ( !class_exists('Parsedown') ) {
require_once(dirname(__FILE__) . '/vendor/Parsedown.php');
}
$instance = Parsedown::instance();
return $instance->text($markdown);
}
/**
* Perform a GitHub API request.
*
* @param string $url
* @param array $queryParams
* @return mixed|WP_Error
*/
protected function api($url, $queryParams = array()) {
$variables = array(
'user' => $this->userName,
'repo' => $this->repositoryName,
);
foreach ($variables as $name => $value) {
$url = str_replace('/:' . $name, '/' . urlencode($value), $url);
}
$url = 'https://api.github.com' . $url;
if ( !empty($this->accessToken) ) {
$queryParams['access_token'] = $this->accessToken;
}
if ( !empty($queryParams) ) {
$url = add_query_arg($queryParams, $url);
}
$response = wp_remote_get($url, array('timeout' => 10));
if ( is_wp_error($response) ) {
return $response;
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ( $code === 200 ) {
$document = json_decode($body);
return $document;
}
return new WP_Error(
'puc-github-http-error',
'GitHub API error. HTTP status: ' . $code
);
}
/**
* Set the access token that will be used to make authenticated GitHub API requests.
*
* @param string $accessToken
*/
public function setAccessToken($accessToken) {
$this->accessToken = $accessToken;
}
/**
* Get the contents of a file from a specific branch or tag.
*
* @param string $path File name.
* @param string $ref
* @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error.
*/
protected function getRemoteFile($path, $ref = 'master') {
$apiUrl = '/repos/:user/:repo/contents/' . $path;
$response = $this->api($apiUrl, array('ref' => $ref));
if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) {
return null;
}
return base64_decode($response->content);
}
/**
* Parse plugin metadata from the header comment.
* This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php.
*
* @param $content
* @return array
*/
protected function getFileHeader($content) {
$headers = array(
'Name' => 'Plugin Name',
'PluginURI' => 'Plugin URI',
'Version' => 'Version',
'Description' => 'Description',
'Author' => 'Author',
'AuthorURI' => 'Author URI',
'TextDomain' => 'Text Domain',
'DomainPath' => 'Domain Path',
'Network' => 'Network',
//The newest WordPress version that this plugin requires or has been tested with.
//We support several different formats for compatibility with other libraries.
'Tested WP' => 'Tested WP',
'Requires WP' => 'Requires WP',
'Tested up to' => 'Tested up to',
'Requires at least' => 'Requires at least',
);
$content = str_replace("\r", "\n", $content); //Normalize line endings.
$results = array();
foreach ($headers as $field => $name) {
$success = preg_match('/^[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi', $content, $matches);
if ( ($success === 1) && $matches[1] ) {
$results[$field] = _cleanup_header_comment($matches[1]);
} else {
$results[$field] = '';
}
}
return $results;
}
/**
* Copy plugin metadata from a file header to a PluginInfo object.
*
* @param array $fileHeader
* @param PluginInfo_2_0 $pluginInfo
*/
protected function setInfoFromHeader($fileHeader, $pluginInfo) {
$headerToPropertyMap = array(
'Version' => 'version',
'Name' => 'name',
'PluginURI' => 'homepage',
'Author' => 'author',
'AuthorName' => 'author',
'AuthorURI' => 'author_homepage',
'Requires WP' => 'requires',
'Tested WP' => 'tested',
'Requires at least' => 'requires',
'Tested up to' => 'tested',
);
foreach ($headerToPropertyMap as $headerName => $property) {
if ( isset($fileHeader[$headerName]) && !empty($fileHeader[$headerName]) ) {
$pluginInfo->$property = $fileHeader[$headerName];
}
}
if ( !isset($pluginInfo->sections) ) {
$pluginInfo->sections = array();
}
if ( !empty($fileHeader['Description']) ) {
$pluginInfo->sections['description'] = $fileHeader['Description'];
}
}
protected function parseReadme($content) {
if ( !class_exists('PucReadmeParser') ) {
require_once(dirname(__FILE__) . '/vendor/readme-parser.php');
}
$parser = new PucReadmeParser();
return $parser->parse_readme_contents($content);
}
/**
* Check if the currently installed version has a readme.txt file.
*
* @return bool
*/
protected function readmeTxtExistsLocally() {
$pluginDirectory = dirname($this->pluginAbsolutePath);
if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) {
return false;
}
return is_file($pluginDirectory . '/readme.txt');
}
/**
* Generate a URL to download a ZIP archive of the specified branch/tag/etc.
*
* @param string $ref
* @return string
*/
protected function buildArchiveDownloadUrl($ref = 'master') {
$url = sprintf(
'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s',
urlencode($this->userName),
urlencode($this->repositoryName),
urlencode($ref)
);
if ( !empty($this->accessToken) ) {
$url = add_query_arg('access_token', $this->accessToken, $url);
}
return $url;
}
}
endif;

View File

@ -1,23 +1,23 @@
<?php
/**
* Plugin Update Checker Library 1.6.3
* Plugin Update Checker Library 2.0.0
* http://w-shadow.com/
*
* Copyright 2015 Janis Elsts
* Released under the MIT license. See license.txt for details.
*/
if ( !class_exists('PluginUpdateChecker_1_6') ):
if ( !class_exists('PluginUpdateChecker_2_0') ):
/**
* A custom plugin update checker.
*
* @author Janis Elsts
* @copyright 2015
* @version 1.6
* @version 2.0
* @access public
*/
class PluginUpdateChecker_1_6 {
class PluginUpdateChecker_2_0 {
public $metadataUrl = ''; //The URL of the plugin's metadata file.
public $pluginAbsolutePath = ''; //Full path of the main plugin file.
public $pluginFile = ''; //Plugin filename relative to the plugins directory. Many WP APIs use this to identify plugins.
@ -223,8 +223,9 @@ class PluginUpdateChecker_1_6 {
//Try to parse the response
$pluginInfo = null;
if ( !is_wp_error($result) && isset($result['response']['code']) && ($result['response']['code'] == 200) && !empty($result['body']) ){
$pluginInfo = PluginInfo_1_6::fromJson($result['body'], $this->debugMode);
$pluginInfo = PluginInfo_2_0::fromJson($result['body'], $this->debugMode);
$pluginInfo->filename = $this->pluginFile;
$pluginInfo->slug = $this->slug;
} else if ( $this->debugMode ) {
$message = sprintf("The URL %s does not point to a valid plugin metadata file. ", $url);
if ( is_wp_error($result) ) {
@ -255,7 +256,7 @@ class PluginUpdateChecker_1_6 {
if ( $pluginInfo == null ){
return null;
}
return PluginUpdate_1_6::fromPluginInfo($pluginInfo);
return PluginUpdate_2_0::fromPluginInfo($pluginInfo);
}
/**
@ -268,25 +269,7 @@ class PluginUpdateChecker_1_6 {
return $this->cachedInstalledVersion;
}
if ( !function_exists('get_plugin_data') ){
require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
}
if ( !is_file($this->pluginAbsolutePath) ) {
//This can happen if the plugin filename is wrong.
if ( $this->debugMode ) {
trigger_error(
sprintf(
"Can't to read the Version header for %s. The file does not exist.",
$this->pluginFile
),
E_USER_WARNING
);
}
return null;
}
$pluginHeader = get_plugin_data($this->pluginAbsolutePath, false, false);
$pluginHeader = $this->getPluginHeader();
if ( isset($pluginHeader['Version']) ) {
$this->cachedInstalledVersion = $pluginHeader['Version'];
return $pluginHeader['Version'];
@ -295,7 +278,7 @@ class PluginUpdateChecker_1_6 {
if ( $this->debugMode ) {
trigger_error(
sprintf(
"Can't to read the Version header for %s. The filename is incorrect or is not a plugin.",
"Can't to read the Version header for '%s'. The filename is incorrect or is not a plugin.",
$this->pluginFile
),
E_USER_WARNING
@ -305,6 +288,32 @@ class PluginUpdateChecker_1_6 {
}
}
/**
* Get plugin's metadata from its file header.
*
* @return array
*/
protected function getPluginHeader() {
if ( !is_file($this->pluginAbsolutePath) ) {
//This can happen if the plugin filename is wrong.
if ( $this->debugMode ) {
trigger_error(
sprintf(
"Can't to read the plugin header for '%s'. The file does not exist.",
$this->pluginFile
),
E_USER_WARNING
);
}
return array();
}
if ( !function_exists('get_plugin_data') ){
require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
}
return get_plugin_data($this->pluginAbsolutePath, false, false);
}
/**
* Check for plugin updates.
* The results are stored in the DB option specified in $optionName.
@ -410,7 +419,7 @@ class PluginUpdateChecker_1_6 {
}
if ( !empty($state) && isset($state->update) && is_object($state->update) ){
$state->update = PluginUpdate_1_6::fromObject($state->update);
$state->update = PluginUpdate_2_0::fromObject($state->update);
}
return $state;
}
@ -452,7 +461,9 @@ class PluginUpdateChecker_1_6 {
* @return mixed
*/
public function injectInfo($result, $action = null, $args = null){
$relevant = ($action == 'plugin_information') && isset($args->slug) && ($args->slug == $this->slug);
$relevant = ($action == 'plugin_information') && isset($args->slug) && (
($args->slug == $this->slug) || ($args->slug == dirname($this->pluginFile))
);
if ( !$relevant ){
return $result;
}
@ -823,17 +834,17 @@ class PluginUpdateChecker_1_6 {
endif;
if ( !class_exists('PluginInfo_1_6') ):
if ( !class_exists('PluginInfo_2_0') ):
/**
* A container class for holding and transforming various plugin metadata.
*
* @author Janis Elsts
* @copyright 2015
* @version 1.6.2
* @version 2.0
* @access public
*/
class PluginInfo_1_6 {
class PluginInfo_2_0 {
//Most fields map directly to the contents of the plugin's info.json file.
//See the relevant docs for a description of their meaning.
public $name;
@ -953,17 +964,17 @@ class PluginInfo_1_6 {
endif;
if ( !class_exists('PluginUpdate_1_6') ):
if ( !class_exists('PluginUpdate_2_0') ):
/**
* A simple container class for holding information about an available update.
*
* @author Janis Elsts
* @copyright 2015
* @version 1.6
* @version 2.0
* @access public
*/
class PluginUpdate_1_6 {
class PluginUpdate_2_0 {
public $id = 0;
public $slug;
public $version;
@ -985,7 +996,7 @@ class PluginUpdate_1_6 {
//Since update-related information is simply a subset of the full plugin info,
//we can parse the update JSON as if it was a plugin info string, then copy over
//the parts that we care about.
$pluginInfo = PluginInfo_1_6::fromJson($json, $triggerErrors);
$pluginInfo = PluginInfo_2_0::fromJson($json, $triggerErrors);
if ( $pluginInfo != null ) {
return self::fromPluginInfo($pluginInfo);
} else {
@ -1158,23 +1169,26 @@ class PucFactory {
endif;
require_once(dirname(__FILE__) . '/github-checker.php');
//Register classes defined in this file with the factory.
PucFactory::addVersion('PluginUpdateChecker', 'PluginUpdateChecker_1_6', '1.6');
PucFactory::addVersion('PluginUpdate', 'PluginUpdate_1_6', '1.6');
PucFactory::addVersion('PluginInfo', 'PluginInfo_1_6', '1.6');
PucFactory::addVersion('PluginUpdateChecker', 'PluginUpdateChecker_2_0', '2.0');
PucFactory::addVersion('PluginUpdate', 'PluginUpdate_2_0', '2.0');
PucFactory::addVersion('PluginInfo', 'PluginInfo_2_0', '2.0');
PucFactory::addVersion('PucGitHubChecker', 'PucGitHubChecker_2_0', '2.0');
/**
* Create non-versioned variants of the update checker classes. This allows for backwards
* compatibility with versions that did not use a factory, and it simplifies doc-comments.
*/
if ( !class_exists('PluginUpdateChecker') ) {
class PluginUpdateChecker extends PluginUpdateChecker_1_6 { }
class PluginUpdateChecker extends PluginUpdateChecker_2_0 { }
}
if ( !class_exists('PluginUpdate') ) {
class PluginUpdate extends PluginUpdate_1_6 {}
class PluginUpdate extends PluginUpdate_2_0 {}
}
if ( !class_exists('PluginInfo') ) {
class PluginInfo extends PluginInfo_1_6 {}
class PluginInfo extends PluginInfo_2_0 {}
}

1535
vendor/Parsedown.php vendored Normal file

File diff suppressed because it is too large Load Diff

331
vendor/readme-parser.php vendored Normal file
View File

@ -0,0 +1,331 @@
<?php
/**
* This is a slightly modified version of github.com/markjaquith/WordPress-Plugin-Readme-Parser
* It uses Parsedown instead of the "Markdown Extra" parser.
*/
Class PucReadmeParser {
function __construct() {
// This space intentially blank
}
function parse_readme( $file ) {
$file_contents = @implode('', @file($file));
return $this->parse_readme_contents( $file_contents );
}
function parse_readme_contents( $file_contents ) {
$file_contents = str_replace(array("\r\n", "\r"), "\n", $file_contents);
$file_contents = trim($file_contents);
if ( 0 === strpos( $file_contents, "\xEF\xBB\xBF" ) )
$file_contents = substr( $file_contents, 3 );
// Markdown transformations
$file_contents = preg_replace( "|^###([^#]+)#*?\s*?\n|im", '=$1='."\n", $file_contents );
$file_contents = preg_replace( "|^##([^#]+)#*?\s*?\n|im", '==$1=='."\n", $file_contents );
$file_contents = preg_replace( "|^#([^#]+)#*?\s*?\n|im", '===$1==='."\n", $file_contents );
// === Plugin Name ===
// Must be the very first thing.
if ( !preg_match('|^===(.*)===|', $file_contents, $_name) )
return array(); // require a name
$name = trim($_name[1], '=');
$name = $this->sanitize_text( $name );
$file_contents = $this->chop_string( $file_contents, $_name[0] );
// Requires at least: 1.5
if ( preg_match('|Requires at least:(.*)|i', $file_contents, $_requires_at_least) )
$requires_at_least = $this->sanitize_text($_requires_at_least[1]);
else
$requires_at_least = NULL;
// Tested up to: 2.1
if ( preg_match('|Tested up to:(.*)|i', $file_contents, $_tested_up_to) )
$tested_up_to = $this->sanitize_text( $_tested_up_to[1] );
else
$tested_up_to = NULL;
// Stable tag: 10.4-ride-the-fire-eagle-danger-day
if ( preg_match('|Stable tag:(.*)|i', $file_contents, $_stable_tag) )
$stable_tag = $this->sanitize_text( $_stable_tag[1] );
else
$stable_tag = NULL; // we assume trunk, but don't set it here to tell the difference between specified trunk and default trunk
// Tags: some tag, another tag, we like tags
if ( preg_match('|Tags:(.*)|i', $file_contents, $_tags) ) {
$tags = preg_split('|,[\s]*?|', trim($_tags[1]));
foreach ( array_keys($tags) as $t )
$tags[$t] = $this->sanitize_text( $tags[$t] );
} else {
$tags = array();
}
// Contributors: markjaquith, mdawaffe, zefrank
$contributors = array();
if ( preg_match('|Contributors:(.*)|i', $file_contents, $_contributors) ) {
$temp_contributors = preg_split('|,[\s]*|', trim($_contributors[1]));
foreach ( array_keys($temp_contributors) as $c ) {
$tmp_sanitized = $this->user_sanitize( $temp_contributors[$c] );
if ( strlen(trim($tmp_sanitized)) > 0 )
$contributors[$c] = $tmp_sanitized;
unset($tmp_sanitized);
}
}
// Donate Link: URL
if ( preg_match('|Donate link:(.*)|i', $file_contents, $_donate_link) )
$donate_link = esc_url( $_donate_link[1] );
else
$donate_link = NULL;
// togs, conts, etc are optional and order shouldn't matter. So we chop them only after we've grabbed their values.
foreach ( array('tags', 'contributors', 'requires_at_least', 'tested_up_to', 'stable_tag', 'donate_link') as $chop ) {
if ( $$chop ) {
$_chop = '_' . $chop;
$file_contents = $this->chop_string( $file_contents, ${$_chop}[0] );
}
}
$file_contents = trim($file_contents);
// short-description fu
if ( !preg_match('/(^(.*?))^[\s]*=+?[\s]*.+?[\s]*=+?/ms', $file_contents, $_short_description) )
$_short_description = array( 1 => &$file_contents, 2 => &$file_contents );
$short_desc_filtered = $this->sanitize_text( $_short_description[2] );
$short_desc_length = strlen($short_desc_filtered);
$short_description = substr($short_desc_filtered, 0, 150);
if ( $short_desc_length > strlen($short_description) )
$truncated = true;
else
$truncated = false;
if ( $_short_description[1] )
$file_contents = $this->chop_string( $file_contents, $_short_description[1] ); // yes, the [1] is intentional
// == Section ==
// Break into sections
// $_sections[0] will be the title of the first section, $_sections[1] will be the content of the first section
// the array alternates from there: title2, content2, title3, content3... and so forth
$_sections = preg_split('/^[\s]*==[\s]*(.+?)[\s]*==/m', $file_contents, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);
$sections = array();
for ( $i=1; $i <= count($_sections); $i +=2 ) {
$_sections[$i] = preg_replace('/^[\s]*=[\s]+(.+?)[\s]+=/m', '<h4>$1</h4>', $_sections[$i]);
$_sections[$i] = $this->filter_text( $_sections[$i], true );
$title = $this->sanitize_text( $_sections[$i-1] );
$sections[str_replace(' ', '_', strtolower($title))] = array('title' => $title, 'content' => $_sections[$i]);
}
// Special sections
// This is where we nab our special sections, so we can enforce their order and treat them differently, if needed
// upgrade_notice is not a section, but parse it like it is for now
$final_sections = array();
foreach ( array('description', 'installation', 'frequently_asked_questions', 'screenshots', 'changelog', 'change_log', 'upgrade_notice') as $special_section ) {
if ( isset($sections[$special_section]) ) {
$final_sections[$special_section] = $sections[$special_section]['content'];
unset($sections[$special_section]);
}
}
if ( isset($final_sections['change_log']) && empty($final_sections['changelog']) )
$final_sections['changelog'] = $final_sections['change_log'];
$final_screenshots = array();
if ( isset($final_sections['screenshots']) ) {
preg_match_all('|<li>(.*?)</li>|s', $final_sections['screenshots'], $screenshots, PREG_SET_ORDER);
if ( $screenshots ) {
foreach ( (array) $screenshots as $ss )
$final_screenshots[] = $ss[1];
}
}
// Parse the upgrade_notice section specially:
// 1.0 => blah, 1.1 => fnord
$upgrade_notice = array();
if ( isset($final_sections['upgrade_notice']) ) {
$split = preg_split( '#<h4>(.*?)</h4>#', $final_sections['upgrade_notice'], -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
for ( $i = 0; $i < count( $split ); $i += 2 )
$upgrade_notice[$this->sanitize_text( $split[$i] )] = substr( $this->sanitize_text( $split[$i + 1] ), 0, 300 );
unset( $final_sections['upgrade_notice'] );
}
// No description?
// No problem... we'll just fall back to the old style of description
// We'll even let you use markup this time!
$excerpt = false;
if ( !isset($final_sections['description']) ) {
$final_sections = array_merge(array('description' => $this->filter_text( $_short_description[2], true )), $final_sections);
$excerpt = true;
}
// dump the non-special sections into $remaining_content
// their order will be determined by their original order in the readme.txt
$remaining_content = '';
foreach ( $sections as $s_name => $s_data ) {
$remaining_content .= "\n<h3>{$s_data['title']}</h3>\n{$s_data['content']}";
}
$remaining_content = trim($remaining_content);
// All done!
// $r['tags'] and $r['contributors'] are simple arrays
// $r['sections'] is an array with named elements
$r = array(
'name' => $name,
'tags' => $tags,
'requires_at_least' => $requires_at_least,
'tested_up_to' => $tested_up_to,
'stable_tag' => $stable_tag,
'contributors' => $contributors,
'donate_link' => $donate_link,
'short_description' => $short_description,
'screenshots' => $final_screenshots,
'is_excerpt' => $excerpt,
'is_truncated' => $truncated,
'sections' => $final_sections,
'remaining_content' => $remaining_content,
'upgrade_notice' => $upgrade_notice
);
return $r;
}
function chop_string( $string, $chop ) { // chop a "prefix" from a string: Agressive! uses strstr not 0 === strpos
if ( $_string = strstr($string, $chop) ) {
$_string = substr($_string, strlen($chop));
return trim($_string);
} else {
return trim($string);
}
}
function user_sanitize( $text, $strict = false ) { // whitelisted chars
if ( function_exists('user_sanitize') ) // bbPress native
return user_sanitize( $text, $strict );
if ( $strict ) {
$text = preg_replace('/[^a-z0-9-]/i', '', $text);
$text = preg_replace('|-+|', '-', $text);
} else {
$text = preg_replace('/[^a-z0-9_-]/i', '', $text);
}
return $text;
}
function sanitize_text( $text ) { // not fancy
$text = strip_tags($text);
$text = esc_html($text);
$text = trim($text);
return $text;
}
function filter_text( $text, $markdown = false ) { // fancy, Markdown
$text = trim($text);
$text = call_user_func( array( __CLASS__, 'code_trick' ), $text, $markdown ); // A better parser than Markdown's for: backticks -> CODE
if ( $markdown ) { // Parse markdown.
if ( !class_exists('Parsedown') ) {
require_once(dirname(__FILE__) . '/Parsedown.php');
}
$instance = Parsedown::instance();
$text = $instance->text($text);
}
$allowed = array(
'a' => array(
'href' => array(),
'title' => array(),
'rel' => array()),
'blockquote' => array('cite' => array()),
'br' => array(),
'p' => array(),
'code' => array(),
'pre' => array(),
'em' => array(),
'strong' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
'h3' => array(),
'h4' => array()
);
$text = balanceTags($text);
$text = wp_kses( $text, $allowed );
$text = trim($text);
return $text;
}
function code_trick( $text, $markdown ) { // Don't use bbPress native function - it's incompatible with Markdown
// If doing markdown, first take any user formatted code blocks and turn them into backticks so that
// markdown will preserve things like underscores in code blocks
if ( $markdown )
$text = preg_replace_callback("!(<pre><code>|<code>)(.*?)(</code></pre>|</code>)!s", array( __CLASS__,'decodeit'), $text);
$text = str_replace(array("\r\n", "\r"), "\n", $text);
if ( !$markdown ) {
// This gets the "inline" code blocks, but can't be used with Markdown.
$text = preg_replace_callback("|(`)(.*?)`|", array( __CLASS__, 'encodeit'), $text);
// This gets the "block level" code blocks and converts them to PRE CODE
$text = preg_replace_callback("!(^|\n)`(.*?)`!s", array( __CLASS__, 'encodeit'), $text);
} else {
// Markdown can do inline code, we convert bbPress style block level code to Markdown style
$text = preg_replace_callback("!(^|\n)([ \t]*?)`(.*?)`!s", array( __CLASS__, 'indent'), $text);
}
return $text;
}
function indent( $matches ) {
$text = $matches[3];
$text = preg_replace('|^|m', $matches[2] . ' ', $text);
return $matches[1] . $text;
}
function encodeit( $matches ) {
if ( function_exists('encodeit') ) // bbPress native
return encodeit( $matches );
$text = trim($matches[2]);
$text = htmlspecialchars($text, ENT_QUOTES);
$text = str_replace(array("\r\n", "\r"), "\n", $text);
$text = preg_replace("|\n\n\n+|", "\n\n", $text);
$text = str_replace('&amp;lt;', '&lt;', $text);
$text = str_replace('&amp;gt;', '&gt;', $text);
$text = "<code>$text</code>";
if ( "`" != $matches[1] )
$text = "<pre>$text</pre>";
return $text;
}
function decodeit( $matches ) {
if ( function_exists('decodeit') ) // bbPress native
return decodeit( $matches );
$text = $matches[2];
$trans_table = array_flip(get_html_translation_table(HTML_ENTITIES));
$text = strtr($text, $trans_table);
$text = str_replace('<br />', '', $text);
$text = str_replace('&#38;', '&', $text);
$text = str_replace('&#39;', "'", $text);
if ( '<pre><code>' == $matches[1] )
$text = "\n$text\n";
return "`$text`";
}
} // end class
Class Automattic_Readme extends PucReadmeParser {}