WP Plugin Starter 1.0.0: a fresh start
The parts every wpallstars plugin needs: settings screen with search and an empty General tab, Read Me tab, features as classes, replaced plugins, the shared GitHub updater, release and check scripts, CI and the shared rules. scripts/rename-plugin.sh starts a new plugin; scripts/sync-core.sh keeps plugins' core files in step with this starter. Built with aidevops (https://aidevops.sh).
This commit is contained in:
commit
2f91c3e6af
62 files changed
+10929
No files matched your search
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* Base class for WP Plugin Starter features.
|
||||
*
|
||||
* A feature is a self-contained class that:
|
||||
* - declares its settings in settings() (the on/off switch first, keyed by KEY,
|
||||
* then any options with 'parent' => KEY);
|
||||
* - registers its hooks in boot(), which runs on `init` (priority 0, before
|
||||
* widgets_init) for every registered feature. Disabled features should
|
||||
* return early so they cost nothing;
|
||||
* - optionally imports settings from the plugin it replaces in migrate().
|
||||
*
|
||||
* While a plugin that a setting replaces is active, the setting waits:
|
||||
* enabled() is false and that plugin keeps doing the job, so the two never
|
||||
* run side by side (two admin bar menus, analytics loaded twice). The
|
||||
* settings card says so, with a deactivate link.
|
||||
*
|
||||
* Register extra features with the `wpstarter_features` filter.
|
||||
*
|
||||
* @package WPStarter
|
||||
* @since 0.3.0
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
abstract class WPStarter_Feature {
|
||||
|
||||
/** Setting key of the feature's on/off switch. */
|
||||
const KEY = '';
|
||||
|
||||
/**
|
||||
* Settings schema entries for this feature.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function settings() {
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature is switched on and not waiting for a plugin it
|
||||
* replaces to be deactivated.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function enabled() {
|
||||
return static::switched_on() && !self::replaced_active(static::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature's switch is on, even if it is waiting.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function switched_on() {
|
||||
return '' !== static::KEY && (bool) WPStarter_Settings::get(static::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the active plugins that a setting replaces.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @return array<string,string> slug => name
|
||||
*/
|
||||
public static function replaced_active($key) {
|
||||
$schema = WPStarter_Settings::schema();
|
||||
if (empty($schema[$key]['replaces']) || !is_array($schema[$key]['replaces'])) {
|
||||
return array();
|
||||
}
|
||||
return array_intersect_key($schema[$key]['replaces'], self::active_plugins());
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins active on this site or network-wide, keyed by folder name.
|
||||
*
|
||||
* @return array<string,string> slug => plugin file
|
||||
*/
|
||||
public static function active_plugins() {
|
||||
static $active = null;
|
||||
if (null === $active) {
|
||||
$files = self::stored_active_plugins();
|
||||
if (is_multisite()) {
|
||||
$files = array_merge($files, array_keys((array) get_site_option('active_sitewide_plugins', array())));
|
||||
}
|
||||
$active = array();
|
||||
foreach ($files as $file) {
|
||||
$slug = dirname((string) $file);
|
||||
if ('.' !== $slug) {
|
||||
$active[$slug] = (string) $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin files active on this site (not network-wide), as stored: a
|
||||
* plugin skipped on this request still counts.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function stored_active_plugins() {
|
||||
$files = get_option('active_plugins', array());
|
||||
$files = is_array($files) ? array_values(array_filter($files, 'is_string')) : array();
|
||||
/**
|
||||
* Filter the active plugin files as stored, for code that skips
|
||||
* plugins on some requests (WP Plugin Starter's "Load plugins only
|
||||
* where needed" filters the option itself).
|
||||
*
|
||||
* @param string[] $files Plugin files, such as "akismet/akismet.php".
|
||||
*/
|
||||
$files = apply_filters('wpstarter_stored_active_plugins', $files);
|
||||
return is_array($files) ? array_values(array_filter($files, 'is_string')) : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether some active plugins are skipped on this request, so things
|
||||
* they register (post types, widgets) may be missing.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function plugins_skipped() {
|
||||
/**
|
||||
* Filter whether some active plugins are skipped on this request.
|
||||
*
|
||||
* @param bool $skipped Whether they are.
|
||||
*/
|
||||
return (bool) apply_filters('wpstarter_plugins_skipped', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings once, when the stored settings version is older than
|
||||
* WPStarter_Settings::DB_VERSION. Only fill keys that are not stored yet.
|
||||
*
|
||||
* @param array $options Stored settings (raw, without defaults).
|
||||
* @param int $from_version Stored settings version before this upgrade.
|
||||
* @return array
|
||||
*/
|
||||
public static function migrate(array $options, $from_version) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting during migrate() unless it is already stored.
|
||||
* (Named import_setting() so features may keep their own import() methods.)
|
||||
*
|
||||
* @param array $options Stored settings.
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $value Raw value; null skips.
|
||||
* @return array
|
||||
*/
|
||||
protected static function import_setting(array $options, $key, $value) {
|
||||
$schema = WPStarter_Settings::schema();
|
||||
if (null !== $value && !array_key_exists($key, $options) && isset($schema[$key])) {
|
||||
$options[$key] = WPStarter_Settings::sanitize_value($value, $schema[$key]);
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role options for multi settings.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function role_options() {
|
||||
$roles = array();
|
||||
foreach (wp_roles()->get_names() as $role => $name) {
|
||||
$roles[$role] = translate_user_role($name);
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role options for settings that restrict people, without roles that can
|
||||
* manage options (such as Administrator): current_user_in_roles() never
|
||||
* matches them, so offering them would only mislead.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function restrictable_role_options() {
|
||||
$roles = array();
|
||||
foreach (wp_roles()->roles as $role => $data) {
|
||||
if (empty($data['capabilities']['manage_options'])) {
|
||||
$roles[$role] = translate_user_role($data['name']);
|
||||
}
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user has one of the roles. Users who can manage
|
||||
* options are never matched, so admins cannot lock themselves out.
|
||||
*
|
||||
* @param mixed $roles Role slugs.
|
||||
* @return bool
|
||||
*/
|
||||
public static function current_user_in_roles($roles) {
|
||||
$user = wp_get_current_user();
|
||||
if (!$user->exists() || user_can($user, 'manage_options')) {
|
||||
return false;
|
||||
}
|
||||
return (bool) array_intersect((array) $user->roles, (array) $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a batch that began at $start has time for one more item.
|
||||
*
|
||||
* The batch keeps to its own budget, and also to PHP's time limit for
|
||||
* the whole request: WP-Cron runs every due batch in one request, so two
|
||||
* 20-second batches would pass a 30-second limit. Where the host allows
|
||||
* it, the limit is restarted for the next item instead.
|
||||
*
|
||||
* @param float $start microtime(true) when the batch began.
|
||||
* @param int $budget Seconds the batch may run.
|
||||
* @return bool
|
||||
*/
|
||||
public static function more_time($start, $budget) {
|
||||
$now = microtime(true);
|
||||
if ($now - $start >= $budget) {
|
||||
return false;
|
||||
}
|
||||
$limit = (int) ini_get('max_execution_time');
|
||||
if ($limit <= 0) {
|
||||
return true;
|
||||
}
|
||||
// set_time_limit() restarts the count; hosts may disable it.
|
||||
if (function_exists('set_time_limit') && @set_time_limit(max($limit, 60))) { // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions -- may be disabled by the host.
|
||||
return true;
|
||||
}
|
||||
$begun = isset($_SERVER['REQUEST_TIME_FLOAT']) ? (float) $_SERVER['REQUEST_TIME_FLOAT'] : $start;
|
||||
$used = $now - $begun;
|
||||
// On Linux the limit counts CPU time of every thread, and image
|
||||
// libraries use several, so CPU time can run ahead of the clock.
|
||||
// getrusage() counts the whole process, which under PHP-FPM serves
|
||||
// many requests (thousands of seconds on a test site), so only CPU
|
||||
// time since the first call in this request counts, plus the clock
|
||||
// time before it.
|
||||
$cpu = self::cpu_seconds();
|
||||
if (null !== $cpu) {
|
||||
if (null === self::$cpu_base) {
|
||||
self::$cpu_base = array($cpu, $now - $begun);
|
||||
}
|
||||
$used = max($used, $cpu - self::$cpu_base[0] + self::$cpu_base[1]);
|
||||
}
|
||||
return $used < $limit - min(10, $limit / 3);
|
||||
}
|
||||
|
||||
/** CPU seconds and request seconds at the first more_time() check. @var array|null */
|
||||
private static $cpu_base = null;
|
||||
|
||||
/**
|
||||
* CPU seconds this process has used, or null when unknown.
|
||||
*
|
||||
* @return float|null
|
||||
*/
|
||||
private static function cpu_seconds() {
|
||||
if (!function_exists('getrusage')) {
|
||||
return null;
|
||||
}
|
||||
$usage = getrusage();
|
||||
if (!is_array($usage) || !isset($usage['ru_utime.tv_sec'], $usage['ru_stime.tv_sec'], $usage['ru_utime.tv_usec'], $usage['ru_stime.tv_usec'])) {
|
||||
return null;
|
||||
}
|
||||
return $usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec'] + ($usage['ru_utime.tv_usec'] + $usage['ru_stime.tv_usec']) / 1e6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
abstract public static function boot();
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
<?php
|
||||
/**
|
||||
* WP Plugin Starter settings store.
|
||||
*
|
||||
* Every setting lives in a single `wpstarter_options` array and is described
|
||||
* by a schema entry (type, default, UI metadata) declared by its feature.
|
||||
* Features read values with WPStarter_Settings::get(); the admin UI renders
|
||||
* cards from the same schema; the AJAX endpoint and the Settings API sanitize
|
||||
* through it. Add settings with the `wpstarter_settings_schema` filter or by
|
||||
* registering a feature (see WPStarter_Feature).
|
||||
*
|
||||
* @package WPStarter
|
||||
* @since 0.3.0
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WPStarter_Settings {
|
||||
|
||||
/** Option that stores every setting. */
|
||||
const OPTION = 'wpstarter_options';
|
||||
|
||||
/** Settings API group. */
|
||||
const GROUP = 'wpstarter_settings';
|
||||
|
||||
/** Nonce action shared by admin AJAX requests. */
|
||||
const NONCE = 'wpstarter_admin';
|
||||
|
||||
/** Stored schema version, used for one-off migrations. */
|
||||
const DB_VERSION_OPTION = 'wpstarter_db_version';
|
||||
|
||||
/** Current schema version; its history is in WPStarter_Setup. */
|
||||
const DB_VERSION = WPStarter_Setup::DB_VERSION;
|
||||
|
||||
/**
|
||||
* Request-level cache of the resolved schema.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static $schema = null;
|
||||
|
||||
/**
|
||||
* Request-level cache of defaults.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static $defaults = null;
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
// Priority 0, before WPStarter::boot_features() so features read
|
||||
// migrated values, and before widgets_init (init:1).
|
||||
add_action('init', array(__CLASS__, 'maybe_migrate'), 0);
|
||||
add_action('admin_init', array(__CLASS__, 'register_setting'));
|
||||
add_action('wp_ajax_wpstarter_save_setting', array(__CLASS__, 'ajax_save'));
|
||||
add_filter('option_page_capability_' . self::GROUP, array(__CLASS__, 'capability'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user may change WP Plugin Starter's settings.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function can_change() {
|
||||
/**
|
||||
* Filter whether the current user may change WP Plugin Starter's settings
|
||||
* (on top of the manage_options check).
|
||||
*
|
||||
* @param bool $can Whether they may.
|
||||
*/
|
||||
return current_user_can('manage_options') && (bool) apply_filters('wpstarter_can_change_settings', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability options.php checks before saving the settings.
|
||||
*
|
||||
* @param string $capability Capability.
|
||||
* @return string
|
||||
*/
|
||||
public static function capability($capability) {
|
||||
return self::can_change() ? $capability : 'do_not_allow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting definitions, collected from the registered features.
|
||||
*
|
||||
* Keys:
|
||||
* - type: bool | int | text | domains | select | multi | times
|
||||
* - default: default value
|
||||
* - tab: admin tab slug (top-level settings only)
|
||||
* - parent: parent setting key (renders inside the parent's panel)
|
||||
* - options: value => label array, or a callable returning one (select, multi)
|
||||
* - open: multi only; also keep key-like values that are not (yet)
|
||||
* in options, e.g. post types or widgets registered later
|
||||
* - replaces: top-level only; plugin slug => name this setting replaces
|
||||
* - reload: true when the change shows only after a page load; the
|
||||
* saved message then asks to reload the page
|
||||
* - label, description, placeholder, min, max, unit, tokens, rows: UI metadata
|
||||
*
|
||||
* Types also include `url` (one URL or site path), `lines` (one entry
|
||||
* per line, sanitized as plain text) and `media` (a Media Library
|
||||
* picture's ID, chosen with the media dialog).
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function schema() {
|
||||
if (null !== self::$schema) {
|
||||
return self::$schema;
|
||||
}
|
||||
|
||||
$schema = array();
|
||||
foreach (WPStarter::features() as $class) {
|
||||
$schema += (array) $class::settings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the settings schema to add or adjust settings.
|
||||
*
|
||||
* @param array $schema Setting definitions keyed by setting key.
|
||||
*/
|
||||
$schema = (array) apply_filters('wpstarter_settings_schema', $schema);
|
||||
|
||||
foreach ($schema as $key => $field) {
|
||||
if (isset($field['tab']) && is_string($field['tab'])) {
|
||||
$schema[$key]['tab'] = self::resolve_tab($field['tab']);
|
||||
}
|
||||
}
|
||||
|
||||
self::$schema = $schema;
|
||||
|
||||
return self::$schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a select/multi field's options.
|
||||
*
|
||||
* Options may be a callable so they can depend on data registered at
|
||||
* `init` (post types, roles) without building it for every request.
|
||||
*
|
||||
* @param array $field Schema entry.
|
||||
* @return array<string,string> value => label
|
||||
*/
|
||||
public static function options_for(array $field) {
|
||||
if (empty($field['options'])) {
|
||||
return array();
|
||||
}
|
||||
$options = is_callable($field['options']) ? call_user_func($field['options']) : $field['options'];
|
||||
return is_array($options) ? $options : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for every setting.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function defaults() {
|
||||
if (null === self::$defaults) {
|
||||
self::$defaults = array();
|
||||
foreach (self::schema() as $key => $field) {
|
||||
self::$defaults[$key] = isset($field['default']) ? $field['default'] : null;
|
||||
}
|
||||
}
|
||||
return self::$defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* All settings merged over defaults.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function all() {
|
||||
$stored = get_option(self::OPTION, array());
|
||||
if (!is_array($stored)) {
|
||||
$stored = array();
|
||||
}
|
||||
return array_merge(self::defaults(), array_intersect_key($stored, self::schema()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one setting.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @return mixed Value, or null for unknown keys.
|
||||
*/
|
||||
public static function get($key) {
|
||||
$all = self::all();
|
||||
return array_key_exists($key, $all) ? $all[$key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update one setting after sanitizing it.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $value Raw value.
|
||||
* @return mixed|WP_Error Sanitized value, or error for unknown keys.
|
||||
*/
|
||||
public static function set($key, $value) {
|
||||
$schema = self::schema();
|
||||
if (!isset($schema[$key])) {
|
||||
return new WP_Error('wpstarter_unknown_setting', __('Unknown setting.', 'wp-plugin-starter-template'));
|
||||
}
|
||||
|
||||
// Read what is stored now, not the copy loaded when this request
|
||||
// began, so a save made meanwhile is not overwritten.
|
||||
self::flush_cache();
|
||||
$options = self::all();
|
||||
$options[$key] = self::sanitize_value($value, $schema[$key]);
|
||||
update_option(self::OPTION, $options);
|
||||
|
||||
if (!self::stored($key, $options[$key])) {
|
||||
return new WP_Error('wpstarter_not_saved', __('The setting could not be saved. Please try again.', 'wp-plugin-starter-template'));
|
||||
}
|
||||
return $options[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cached copies of the settings so the next read hits the database.
|
||||
*/
|
||||
private static function flush_cache() {
|
||||
wp_cache_delete(self::OPTION, 'options');
|
||||
wp_cache_delete('alloptions', 'options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the database holds a value for a setting (update_option()
|
||||
* returns false both for "unchanged" and for a failed write).
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $value Expected value.
|
||||
* @return bool
|
||||
*/
|
||||
private static function stored($key, $value) {
|
||||
self::flush_cache();
|
||||
$stored = get_option(self::OPTION);
|
||||
return is_array($stored) && array_key_exists($key, $stored) && $stored[$key] === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level settings for a tab.
|
||||
*
|
||||
* @param string $tab Tab slug.
|
||||
* @return array
|
||||
*/
|
||||
public static function fields_for_tab($tab) {
|
||||
return array_filter(self::schema(), function ($field) use ($tab) {
|
||||
return empty($field['parent']) && isset($field['tab']) && $field['tab'] === $tab;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a renamed tab slug to its current slug
|
||||
* (WPStarter_Setup::RENAMED_TABS).
|
||||
*
|
||||
* @param string $tab Tab slug.
|
||||
* @return string
|
||||
*/
|
||||
public static function resolve_tab($tab) {
|
||||
$renamed = WPStarter_Setup::RENAMED_TABS;
|
||||
return array_key_exists($tab, $renamed) ? $renamed[$tab] : $tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level settings that match a search, in schema order.
|
||||
*
|
||||
* Matches the label, description, replaced plugin names and the labels
|
||||
* of child options, ignoring case.
|
||||
*
|
||||
* @param string $query Search text.
|
||||
* @return array
|
||||
*/
|
||||
public static function search($query) {
|
||||
$query = trim((string) $query);
|
||||
if ('' === $query) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$matches = array();
|
||||
foreach (self::schema() as $key => $field) {
|
||||
if (!empty($field['parent']) || empty($field['tab'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$haystack = array(
|
||||
$key,
|
||||
isset($field['label']) ? $field['label'] : '',
|
||||
isset($field['description']) ? $field['description'] : '',
|
||||
);
|
||||
if (!empty($field['replaces']) && is_array($field['replaces'])) {
|
||||
$haystack = array_merge($haystack, array_keys($field['replaces']), array_values($field['replaces']));
|
||||
}
|
||||
foreach (self::children_of($key) as $child) {
|
||||
$haystack[] = isset($child['label']) && empty($child['hidden']) ? (string) $child['label'] : '';
|
||||
}
|
||||
|
||||
$text = implode(' ', array_map('strval', $haystack));
|
||||
$found = function_exists('mb_stripos') ? mb_stripos($text, $query) : stripos($text, $query);
|
||||
if (false !== $found) {
|
||||
$matches[$key] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Child settings rendered in a parent's panel.
|
||||
*
|
||||
* @param string $parent Parent setting key.
|
||||
* @return array
|
||||
*/
|
||||
public static function children_of($parent) {
|
||||
return array_filter(self::schema(), function ($field) use ($parent) {
|
||||
return isset($field['parent']) && $field['parent'] === $parent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a value according to its schema entry.
|
||||
*
|
||||
* Must be idempotent: update_option() re-runs sanitize_all() on the
|
||||
* already-sanitized array.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @param array $field Schema entry.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function sanitize_value($value, array $field) {
|
||||
$type = isset($field['type']) ? $field['type'] : 'text';
|
||||
$default = isset($field['default']) ? $field['default'] : null;
|
||||
|
||||
switch ($type) {
|
||||
case 'bool':
|
||||
return rest_sanitize_boolean($value);
|
||||
|
||||
case 'int':
|
||||
$value = is_numeric($value) ? (int) $value : (int) $default;
|
||||
if (isset($field['min'])) {
|
||||
$value = max((int) $field['min'], $value);
|
||||
}
|
||||
if (isset($field['max'])) {
|
||||
$value = min((int) $field['max'], $value);
|
||||
}
|
||||
return $value;
|
||||
|
||||
case 'domains':
|
||||
return implode("\n", self::parse_domains($value));
|
||||
|
||||
case 'media':
|
||||
// A Media Library picture's ID, or 0.
|
||||
$value = is_numeric($value) ? absint($value) : 0;
|
||||
return $value && wp_attachment_is_image($value) ? $value : 0;
|
||||
|
||||
case 'select':
|
||||
$value = is_scalar($value) ? (string) $value : '';
|
||||
if (array_key_exists($value, self::options_for($field))) {
|
||||
return $value;
|
||||
}
|
||||
// Open selects (another plugin's board or course) keep an
|
||||
// identifier whose plugin is not loaded on this request.
|
||||
return !empty($field['open']) && strlen($value) <= 200 && preg_match('/^[A-Za-z0-9_-]+$/', $value) ? $value : $default;
|
||||
|
||||
case 'multi':
|
||||
$values = is_array($value) ? $value : preg_split('/\s*,\s*/', (string) $value, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$values = array_map('strval', array_filter((array) $values, 'is_scalar'));
|
||||
// Keep the options' order so stored values are stable.
|
||||
$known = array_values(array_intersect(array_map('strval', array_keys(self::options_for($field))), $values));
|
||||
if (empty($field['open'])) {
|
||||
return $known;
|
||||
}
|
||||
// Open lists also keep identifiers that are not registered right now
|
||||
// (class names may contain namespace separators).
|
||||
$extra = array_filter(array_diff($values, $known), function ($item) {
|
||||
return strlen($item) <= 200 && (bool) preg_match('/^[A-Za-z0-9_\\\\-]+$/', $item);
|
||||
});
|
||||
return array_values(array_unique(array_merge($known, $extra)));
|
||||
|
||||
case 'times':
|
||||
return implode(', ', self::parse_times($value));
|
||||
|
||||
case 'url':
|
||||
$value = trim((string) $value);
|
||||
if ('' === $value) {
|
||||
return '';
|
||||
}
|
||||
if ('/' === $value[0] && '/' !== substr($value, 1, 1)) {
|
||||
// Site path: keep it relative so it survives domain changes.
|
||||
return '/' . ltrim(preg_replace('/\s+/', '', sanitize_text_field($value)), '/');
|
||||
}
|
||||
return esc_url_raw($value, array('http', 'https'));
|
||||
|
||||
case 'lines':
|
||||
$lines = preg_split('/[\r\n]+/', (string) $value);
|
||||
// Not sanitize_text_field(): it strips %xx, which URL paths need.
|
||||
$lines = array_filter(array_map(function ($line) {
|
||||
return trim(preg_replace('/[\x00-\x1F\x7F]+/', '', wp_strip_all_tags($line)));
|
||||
}, $lines), function ($line) {
|
||||
return '' !== $line;
|
||||
});
|
||||
return implode("\n", array_values(array_unique($lines)));
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
$value = (string) $value;
|
||||
if (empty($field['tokens'])) {
|
||||
return sanitize_text_field($value);
|
||||
}
|
||||
// sanitize_text_field() drops "%" plus two hex digits, which
|
||||
// breaks tokens such as %date% and %day%; set them aside.
|
||||
$tokens = array();
|
||||
foreach (array_values((array) $field['tokens']) as $i => $token) {
|
||||
$tokens["\u{E000}{$i}\u{E001}"] = (string) $token;
|
||||
}
|
||||
$value = str_replace(array_values($tokens), array_keys($tokens), $value);
|
||||
return str_replace(array_keys($tokens), array_values($tokens), sanitize_text_field($value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a newline/comma separated domain list to bare lowercase hosts.
|
||||
*
|
||||
* @param mixed $value Raw list.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function parse_domains($value) {
|
||||
$lines = preg_split('/[\r\n,]+/', (string) $value);
|
||||
$domains = array();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ('' === $line) {
|
||||
continue;
|
||||
}
|
||||
$host = wp_parse_url(false === strpos($line, '://') ? 'http://' . $line : $line, PHP_URL_HOST);
|
||||
$host = $host ? strtolower(preg_replace('/^www\./i', '', $host)) : '';
|
||||
if ('' !== $host && preg_match('/^[a-z0-9.-]+$/', $host)) {
|
||||
$domains[] = $host;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($domains));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a host is one of the domains or a subdomain of one.
|
||||
*
|
||||
* @param string $host Host name.
|
||||
* @param string[] $domains Normalised domains from parse_domains().
|
||||
* @return bool
|
||||
*/
|
||||
public static function host_matches($host, array $domains) {
|
||||
$host = strtolower(preg_replace('/^www\./i', '', (string) $host));
|
||||
foreach ($domains as $domain) {
|
||||
if ($host === $domain || substr($host, -strlen('.' . $domain)) === '.' . $domain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a list of times of day to sorted, unique 24-hour "HH:MM".
|
||||
*
|
||||
* Accepts "9", "9:30", "09:30", "9.30", "9pm", "9:30 am" separated by
|
||||
* commas, spaces or new lines.
|
||||
*
|
||||
* @param mixed $value Raw list.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function parse_times($value) {
|
||||
preg_match_all('/(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?/i', (string) $value, $matches, PREG_SET_ORDER);
|
||||
$times = array();
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$hour = (int) $match[1];
|
||||
$minute = isset($match[2]) && '' !== $match[2] ? (int) $match[2] : 0;
|
||||
$suffix = isset($match[3]) ? strtolower($match[3]) : '';
|
||||
if ('pm' === $suffix && $hour < 12) {
|
||||
$hour += 12;
|
||||
} elseif ('am' === $suffix && 12 === $hour) {
|
||||
$hour = 0;
|
||||
}
|
||||
if ($hour > 23 || $minute > 59) {
|
||||
continue;
|
||||
}
|
||||
$times[] = sprintf('%02d:%02d', $hour, $minute);
|
||||
}
|
||||
|
||||
$times = array_values(array_unique($times));
|
||||
sort($times);
|
||||
return $times;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the whole array when saved through options.php or update_option().
|
||||
*
|
||||
* @param mixed $input Raw option value.
|
||||
* @return array
|
||||
*/
|
||||
public static function sanitize_all($input) {
|
||||
$input = is_array($input) ? $input : array();
|
||||
$schema = self::schema();
|
||||
$clean = self::all();
|
||||
|
||||
foreach ($input as $key => $value) {
|
||||
if (isset($schema[$key])) {
|
||||
$clean[$key] = self::sanitize_value($value, $schema[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the option with the Settings API.
|
||||
*/
|
||||
public static function register_setting() {
|
||||
register_setting(self::GROUP, self::OPTION, array(
|
||||
'type' => 'object',
|
||||
'sanitize_callback' => array(__CLASS__, 'sanitize_all'),
|
||||
'default' => self::defaults(),
|
||||
'show_in_rest' => false,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: save a single setting (used by instant-save controls).
|
||||
*/
|
||||
public static function ajax_save() {
|
||||
check_ajax_referer(self::NONCE, 'nonce');
|
||||
|
||||
if (!self::can_change()) {
|
||||
wp_send_json_error(array('message' => __('You are not allowed to change these settings.', 'wp-plugin-starter-template')), 403);
|
||||
}
|
||||
|
||||
$key = isset($_POST['key']) ? sanitize_key(wp_unslash($_POST['key'])) : '';
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per schema in set().
|
||||
$raw = isset($_POST['value']) ? wp_unslash($_POST['value']) : '';
|
||||
|
||||
$value = self::set($key, $raw);
|
||||
if (is_wp_error($value)) {
|
||||
wp_send_json_error(array('message' => $value->get_error_message()), 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after a setting is saved from the admin UI.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $value Sanitized value.
|
||||
*/
|
||||
do_action('wpstarter_setting_saved', $key, $value);
|
||||
|
||||
$schema = self::schema();
|
||||
$reload = !empty($schema[$key]['reload']);
|
||||
wp_send_json_success(array(
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'message' => $reload ? __('Saved. Reload the page to see the change.', 'wp-plugin-starter-template') : __('Saved', 'wp-plugin-starter-template'),
|
||||
'reload' => $reload,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* One-off migrations, once per DB_VERSION: WPStarter_Setup::migrate()
|
||||
* (imports that belong to no feature), then each feature's migrate().
|
||||
* The history of versions is in WPStarter_Setup::DB_VERSION.
|
||||
*
|
||||
* Old options are left in place so a downgrade keeps working;
|
||||
* uninstall.php removes ours. Other plugins' options are never touched.
|
||||
*/
|
||||
public static function maybe_migrate() {
|
||||
$from = (int) get_option(self::DB_VERSION_OPTION, 0);
|
||||
if ($from >= self::DB_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
$options = get_option(self::OPTION, array());
|
||||
$options = is_array($options) ? $options : array();
|
||||
$options = (array) WPStarter_Setup::migrate($options, $from);
|
||||
|
||||
foreach (WPStarter::features() as $class) {
|
||||
$options = (array) $class::migrate($options, $from);
|
||||
}
|
||||
|
||||
$clean = self::sanitize_all($options);
|
||||
update_option(self::OPTION, $clean);
|
||||
|
||||
// Only record the version once the settings are stored, so a failed
|
||||
// write is retried on the next request instead of losing imports.
|
||||
self::flush_cache();
|
||||
if (get_option(self::OPTION) != $clean) { // phpcs:ignore Universal.Operators.StrictComparisons -- key order may differ.
|
||||
return;
|
||||
}
|
||||
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* What makes this plugin WP Plugin Starter: its features, settings tabs,
|
||||
* links, settings history and the helpers only it needs.
|
||||
*
|
||||
* The other files in includes/ and admin/ that the starter plugin also has
|
||||
* (the feature registry, settings store, base feature and admin screen)
|
||||
* read this class and differ from the starter's only in names. Keep
|
||||
* anything that only this plugin needs here, in features or in its own
|
||||
* files loaded from here.
|
||||
*
|
||||
* The starter has no features yet, so its settings screen shows the first
|
||||
* settings tab with "No settings yet" and the Read Me tab. Add a feature:
|
||||
* STANDARDS.md → Structure and README.md → Developers.
|
||||
*
|
||||
* @package WPStarter
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
final class WPStarter_Setup {
|
||||
|
||||
/**
|
||||
* Built-in features, in the order their cards appear within each tab.
|
||||
* Each lives in includes/features/class-{lowercase-hyphenated-name}.php,
|
||||
* for example WPStarter_Example in class-wpstarter-example.php.
|
||||
*/
|
||||
const FEATURES = array();
|
||||
|
||||
/**
|
||||
* Features that some builds leave out, loaded only when their file is
|
||||
* present (for example one listed in .distignore-wporg).
|
||||
*/
|
||||
const OPTIONAL_FEATURES = array();
|
||||
|
||||
/**
|
||||
* Settings version. WPStarter_Settings::maybe_migrate() runs
|
||||
* migrate() below and every feature's migrate() once per version.
|
||||
* After a release, bump it for a new or changed import and add a line.
|
||||
*
|
||||
* v1: first version.
|
||||
*/
|
||||
const DB_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Renamed tab slugs, old => new. Settings that still use an old slug
|
||||
* land on the new tab, and old admin links open the new tab.
|
||||
*/
|
||||
const RENAMED_TABS = array();
|
||||
|
||||
/**
|
||||
* Links in the settings screen header; leave one out for no button.
|
||||
*
|
||||
* - website: the maker's website
|
||||
* - support: where people report problems (the plugin's GitHub issues)
|
||||
* - donate: where people can support the maker
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function header_links() {
|
||||
return array(
|
||||
'website' => 'https://www.wpallstars.com/',
|
||||
'support' => 'https://github.com/wpallstars/wp-plugin-starter-template-for-ai-coding/issues',
|
||||
'donate' => 'https://buymeacoffee.com/marcusquinn',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the plugin's own helpers, before the features.
|
||||
*/
|
||||
public static function load() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the helpers' hooks, after the settings store's.
|
||||
*/
|
||||
public static function init() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports that belong to no feature, run once per DB_VERSION before
|
||||
* the features' own (for example settings from the plugin's earlier
|
||||
* names). Never write or delete other plugins' options.
|
||||
*
|
||||
* @param array $options Stored settings (raw, without defaults).
|
||||
* @param int $from_version Stored settings version before this upgrade.
|
||||
* @return array
|
||||
*/
|
||||
public static function migrate(array $options, $from_version) {
|
||||
unset($from_version);
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings tabs, in navigation order. A tab shows only when a setting
|
||||
* uses it; with no settings at all, the first one shows, empty.
|
||||
*
|
||||
* @return array<string,array{label:string,description:string}>
|
||||
*/
|
||||
public static function settings_tabs() {
|
||||
return array(
|
||||
'general' => array(
|
||||
'label' => __('General', 'wp-plugin-starter-template'),
|
||||
'description' => __('WP Plugin Starter\'s settings appear here as features are added.', 'wp-plugin-starter-template'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin requests: load and start the plugin's own admin parts (other
|
||||
* tabs with the wpstarter_admin_tabs filter, scripts with the
|
||||
* wpstarter_admin_enqueue action).
|
||||
*/
|
||||
public static function admin() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* WP Plugin Starter bootstrap and feature registry. The features and anything
|
||||
* else only this plugin needs come from WPStarter_Setup.
|
||||
*
|
||||
* @package WPStarter
|
||||
* @since 0.3.0
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
final class WPStarter {
|
||||
|
||||
/**
|
||||
* Feature classes whose files are loaded: WPStarter_Setup::FEATURES,
|
||||
* plus each of WPStarter_Setup::OPTIONAL_FEATURES this build has.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private static $core_features = array();
|
||||
|
||||
/**
|
||||
* Resolved feature classes.
|
||||
*
|
||||
* @var string[]|null
|
||||
*/
|
||||
private static $features = null;
|
||||
|
||||
/**
|
||||
* Load files and register hooks.
|
||||
*/
|
||||
public static function load() {
|
||||
require_once WPSTARTER_DIR . 'includes/class-wpstarter-settings.php';
|
||||
require_once WPSTARTER_DIR . 'includes/class-wpstarter-feature.php';
|
||||
require_once WPSTARTER_DIR . 'includes/class-wpstarter-setup.php';
|
||||
WPStarter_Setup::load();
|
||||
|
||||
foreach (WPStarter_Setup::FEATURES as $class) {
|
||||
require_once self::feature_file($class);
|
||||
self::$core_features[] = $class;
|
||||
}
|
||||
// Features that some builds leave out (the WordPress.org build has
|
||||
// no GitHub updates) load only when their file is present.
|
||||
foreach (WPStarter_Setup::OPTIONAL_FEATURES as $class) {
|
||||
if (is_readable(self::feature_file($class))) {
|
||||
require_once self::feature_file($class);
|
||||
self::$core_features[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
// The shared GitHub updater (GitHub builds only): registers this
|
||||
// copy; the newest copy on the site loads on plugins_loaded.
|
||||
if (is_readable(WPSTARTER_DIR . 'includes/github-updater/load.php')) {
|
||||
require_once WPSTARTER_DIR . 'includes/github-updater/load.php';
|
||||
}
|
||||
|
||||
WPStarter_Settings::init();
|
||||
WPStarter_Setup::init();
|
||||
// Priority 0, added after WPStarter_Settings::maybe_migrate() so features
|
||||
// read migrated values, and early enough to hook widgets_init (init:1).
|
||||
add_action('init', array(__CLASS__, 'boot_features'), 0);
|
||||
|
||||
if (is_admin()) {
|
||||
require_once WPSTARTER_DIR . 'admin/settings.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File of a feature class.
|
||||
*
|
||||
* @param string $class Class name.
|
||||
* @return string
|
||||
*/
|
||||
private static function feature_file($class) {
|
||||
return WPSTARTER_DIR . 'includes/features/class-' . str_replace('_', '-', strtolower($class)) . '.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered feature classes.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function features() {
|
||||
if (null === self::$features) {
|
||||
/**
|
||||
* Filter the feature classes. Each must extend WPStarter_Feature.
|
||||
*
|
||||
* @param string[] $features Class names.
|
||||
*/
|
||||
$features = (array) apply_filters('wpstarter_features', self::$core_features);
|
||||
self::$features = array_values(array_filter($features, function ($class) {
|
||||
return is_string($class) && class_exists($class) && is_subclass_of($class, 'WPStarter_Feature');
|
||||
}));
|
||||
}
|
||||
return self::$features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot every feature.
|
||||
*/
|
||||
public static function boot_features() {
|
||||
foreach (self::features() as $class) {
|
||||
$class::boot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin deactivated: let features that changed files or scheduled
|
||||
* work outside WordPress's options undo it (a static deactivate() method).
|
||||
*
|
||||
* @param bool $network_wide Deactivated for the whole network.
|
||||
*/
|
||||
public static function deactivate($network_wide = false) {
|
||||
foreach (self::features() as $class) {
|
||||
if (method_exists($class, 'deactivate')) {
|
||||
$class::deactivate((bool) $network_wide);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared GitHub updater: updates from GitHub releases for every installed
|
||||
* plugin that names its GitHub repository.
|
||||
*
|
||||
* Loaded by load.php, which picks the newest copy on the site; see there.
|
||||
* Only in builds made from GitHub releases: the WordPress.org build leaves
|
||||
* this folder out.
|
||||
*
|
||||
* - A plugin takes part by naming its repository in its main file's header:
|
||||
* `GitHub Plugin URI: owner/repo` (the header Git Updater reads, so the
|
||||
* two stay compatible). `Release Asset: true` means "install only the zip
|
||||
* attached to the release, never the source code".
|
||||
* - WordPress does the rest itself: this only tells it that a newer release
|
||||
* exists and where its zip is. The Updates screen, auto-updates, "View
|
||||
* details", the download, the install and the rollback on failure are
|
||||
* WordPress's own. Nothing is removed from or blocked in its update check.
|
||||
* - The latest release of each repository is asked for at most every 12
|
||||
* hours (an hour after a failure), when WordPress checks for updates, and
|
||||
* again when someone presses "Check again" on the Updates screen.
|
||||
* - Public repositories are read from github.com's own pages (the
|
||||
* releases/latest redirect, the asset's download address and the main
|
||||
* file on raw.githubusercontent.com), not the API: without a token the API
|
||||
* allows 60 requests an hour per server address, shared by every site on
|
||||
* a host. The asset must be named {folder}-{version}.zip.
|
||||
* - Private repositories need a GitHub token in wp-config.php
|
||||
* (WPALLSTARS_GITHUB_TOKEN) or from the `wpallstars_github_token` filter,
|
||||
* and are read through the API. The token is sent only to api.github.com,
|
||||
* never stored and never shown.
|
||||
* - Plugins also on WordPress.org keep updating from there unless the
|
||||
* `wpallstars_github_updater_early` filter returns true.
|
||||
* - While Git Updater is active, this waits and Git Updater does the job.
|
||||
* The `wpallstars_github_updater_enabled` filter can turn it off too.
|
||||
*
|
||||
* @package WPStarter
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
final class WPAllStars_GitHub_Updater {
|
||||
|
||||
/** Site transient: latest release of each repository, keyed by owner/repo. */
|
||||
const CACHE = 'wpallstars_github_releases';
|
||||
|
||||
/** How long a release answer and a failed request are kept. */
|
||||
const FRESH = 12 * HOUR_IN_SECONDS;
|
||||
const RETRY = HOUR_IN_SECONDS;
|
||||
const RECENT = MINUTE_IN_SECONDS;
|
||||
|
||||
/** Update IDs this file adds, so its own entries can be told apart. */
|
||||
const ID_PREFIX = 'github.com/';
|
||||
|
||||
/**
|
||||
* Release answers read this request.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static $cache = null;
|
||||
|
||||
/**
|
||||
* Register hooks (plugins_loaded). Whether to run is decided on init,
|
||||
* once plugins have added their filters.
|
||||
*/
|
||||
public static function load() {
|
||||
add_action('init', array(__CLASS__, 'boot'), 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the update hooks, unless switched off or Git Updater is active.
|
||||
*/
|
||||
public static function boot() {
|
||||
/**
|
||||
* Whether to add GitHub releases to WordPress's update check.
|
||||
*
|
||||
* @param bool $enabled False while Git Updater is active.
|
||||
*/
|
||||
if (!apply_filters('wpallstars_github_updater_enabled', !self::git_updater_active())) {
|
||||
return;
|
||||
}
|
||||
// Update checks run in the admin, in cron and in WP-CLI.
|
||||
add_filter('pre_set_site_transient_update_plugins', array(__CLASS__, 'add_updates'));
|
||||
add_filter('plugins_api', array(__CLASS__, 'plugin_information'), 20, 3);
|
||||
add_filter('upgrader_pre_download', array(__CLASS__, 'private_download'), 10, 3);
|
||||
add_filter('upgrader_source_selection', array(__CLASS__, 'fix_folder'), 10, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Git Updater is active on this site or network-wide.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function git_updater_active() {
|
||||
$files = (array) get_option('active_plugins', array());
|
||||
if (is_multisite()) {
|
||||
$files = array_merge($files, array_keys((array) get_site_option('active_sitewide_plugins', array())));
|
||||
}
|
||||
foreach ($files as $file) {
|
||||
if ('git-updater' === dirname((string) $file)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins that name a GitHub repository.
|
||||
*
|
||||
* @return array<string,array{repo:string,asset_only:bool,version:string,name:string}> Plugin file => details.
|
||||
*/
|
||||
public static function plugins() {
|
||||
static $found = null;
|
||||
if (null !== $found) {
|
||||
return $found;
|
||||
}
|
||||
if (!function_exists('get_plugins')) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
$found = array();
|
||||
foreach (get_plugins() as $file => $data) {
|
||||
// Plugins in a folder only: an update replaces the whole folder.
|
||||
if ('.' === dirname($file)) {
|
||||
continue;
|
||||
}
|
||||
$headers = get_file_data(WP_PLUGIN_DIR . '/' . $file, array(
|
||||
'repo' => 'GitHub Plugin URI',
|
||||
'asset' => 'Release Asset',
|
||||
));
|
||||
$repo = self::repo_name($headers['repo']);
|
||||
if ('' === $repo) {
|
||||
continue;
|
||||
}
|
||||
$found[$file] = array(
|
||||
'repo' => $repo,
|
||||
'asset_only' => in_array(strtolower(trim($headers['asset'])), array('true', 'yes', '1'), true),
|
||||
'version' => isset($data['Version']) ? (string) $data['Version'] : '',
|
||||
'name' => isset($data['Name']) ? (string) $data['Name'] : $file,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the plugins updated from GitHub releases.
|
||||
*
|
||||
* @param array $found Plugin file => array(repo, asset_only, version, name).
|
||||
*/
|
||||
$filtered = (array) apply_filters('wpallstars_github_plugins', $found);
|
||||
$found = array();
|
||||
foreach ($filtered as $file => $plugin) {
|
||||
$repo = is_array($plugin) && isset($plugin['repo']) ? self::repo_name($plugin['repo']) : '';
|
||||
if ('' === $repo || '.' === dirname((string) $file)) {
|
||||
continue;
|
||||
}
|
||||
$found[(string) $file] = array(
|
||||
'repo' => $repo,
|
||||
'asset_only' => !empty($plugin['asset_only']),
|
||||
'version' => isset($plugin['version']) ? (string) $plugin['version'] : '',
|
||||
'name' => isset($plugin['name']) ? (string) $plugin['name'] : (string) $file,
|
||||
);
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* owner/repo from a header value ("owner/repo" or a github.com address).
|
||||
*
|
||||
* @param mixed $value Header value.
|
||||
* @return string owner/repo, or '' when it is not one.
|
||||
*/
|
||||
public static function repo_name($value) {
|
||||
$value = trim((string) $value);
|
||||
$value = preg_replace('#^(?:https?://)?(?:www\.)?github\.com/#i', '', $value);
|
||||
$value = preg_replace('#(?:\.git)?/*$#', '', (string) $value);
|
||||
return preg_match('#^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$#', (string) $value) ? (string) $value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Token for a repository, if the site has one.
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @return string
|
||||
*/
|
||||
private static function token($repo) {
|
||||
$token = defined('WPALLSTARS_GITHUB_TOKEN') ? (string) WPALLSTARS_GITHUB_TOKEN : '';
|
||||
/**
|
||||
* Filter the GitHub token used for a repository (private repositories
|
||||
* need one with read access to its contents). Return '' for none.
|
||||
*
|
||||
* @param string $token Token.
|
||||
* @param string $repo owner/repo.
|
||||
*/
|
||||
return trim((string) apply_filters('wpallstars_github_token', $token, $repo));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET from the GitHub API.
|
||||
*
|
||||
* @param string $repo owner/repo (for the token).
|
||||
* @param string $path Path after /repos/{repo}.
|
||||
* @param string $accept Accept header.
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private static function api_get($repo, $path, $accept = 'application/vnd.github+json') {
|
||||
$headers = array(
|
||||
'Accept' => $accept,
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
);
|
||||
$token = self::token($repo);
|
||||
if ('' !== $token) {
|
||||
$headers['Authorization'] = 'Bearer ' . $token;
|
||||
}
|
||||
// No redirects: the token must never travel to another address.
|
||||
return wp_safe_remote_get('https://api.github.com/repos/' . $repo . $path, array(
|
||||
'timeout' => 10,
|
||||
'redirection' => 0,
|
||||
'headers' => $headers,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored release answers.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function cache() {
|
||||
if (null === self::$cache) {
|
||||
$stored = get_site_transient(self::CACHE);
|
||||
self::$cache = is_array($stored) ? $stored : array();
|
||||
}
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone pressed "Check again" on the Updates screen.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function forced() {
|
||||
global $pagenow;
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- core's own link; it only refreshes data.
|
||||
return is_admin() && 'update-core.php' === $pagenow && !empty($_GET['force-check']) && current_user_can('update_plugins');
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest release of a repository, from the cache or GitHub.
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param string $folder Plugin folder (picks the release asset).
|
||||
* @param string $main Main file name in the folder (reads its requirements).
|
||||
* @return array|null Release, or null when there is none or GitHub did not answer.
|
||||
*/
|
||||
public static function release($repo, $folder, $main) {
|
||||
$cache = self::cache();
|
||||
// The asset and requirements depend on the plugin, not only the repository.
|
||||
$key = $repo . '|' . $folder . '/' . $main;
|
||||
$entry = isset($cache[$key]) && is_array($cache[$key]) ? $cache[$key] : null;
|
||||
$age = $entry && isset($entry['checked']) ? time() - (int) $entry['checked'] : PHP_INT_MAX;
|
||||
$keep = $entry && !empty($entry['failed']) ? self::RETRY : self::FRESH;
|
||||
|
||||
if ($entry && $age < $keep && !(self::forced() && $age > self::RECENT)) {
|
||||
return isset($entry['release']) ? $entry['release'] : null;
|
||||
}
|
||||
|
||||
$release = self::fetch($repo, $folder, $main);
|
||||
if (is_wp_error($release)) {
|
||||
// Keep the last good answer, and try again in an hour.
|
||||
$entry = array(
|
||||
'checked' => time(),
|
||||
'failed' => $release->get_error_message(),
|
||||
'release' => $entry && isset($entry['release']) ? $entry['release'] : null,
|
||||
);
|
||||
} else {
|
||||
$entry = array('checked' => time(), 'release' => $release);
|
||||
}
|
||||
|
||||
self::$cache[$key] = $entry;
|
||||
set_site_transient(self::CACHE, self::$cache, 2 * DAY_IN_SECONDS);
|
||||
return $entry['release'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask GitHub for a repository's latest release.
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param string $folder Plugin folder.
|
||||
* @param string $main Main file name.
|
||||
* @return array|null|WP_Error Release, null when there is no usable one, or the error.
|
||||
*/
|
||||
private static function fetch($repo, $folder, $main) {
|
||||
return '' !== self::token($repo) ? self::fetch_api($repo, $folder, $main) : self::fetch_web($repo, $folder, $main);
|
||||
}
|
||||
|
||||
/**
|
||||
* An error for a GitHub answer that was not the expected one.
|
||||
*
|
||||
* @param int $code HTTP status code.
|
||||
* @return WP_Error
|
||||
*/
|
||||
private static function status_error($code) {
|
||||
/* translators: %d: HTTP status code */
|
||||
return new WP_Error('wpallstars_github', sprintf(__('GitHub answered with status %d.', 'wp-plugin-starter-template'), $code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest release from github.com's own pages, for public repositories.
|
||||
* The GitHub API allows 60 requests an hour per server address without
|
||||
* a token, which hosts share between many sites; these pages do not
|
||||
* count towards it.
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param string $folder Plugin folder.
|
||||
* @param string $main Main file name.
|
||||
* @return array|null|WP_Error
|
||||
*/
|
||||
private static function fetch_web($repo, $folder, $main) {
|
||||
// Redirects to the newest release that is not a draft or pre-release.
|
||||
$response = wp_safe_remote_head('https://github.com/' . $repo . '/releases/latest', array(
|
||||
'timeout' => 10,
|
||||
'redirection' => 0,
|
||||
));
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$location = (string) wp_remote_retrieve_header($response, 'location');
|
||||
if (404 === $code) {
|
||||
// No such public repository (private ones need a token).
|
||||
return null;
|
||||
}
|
||||
if ($code < 300 || $code > 399) {
|
||||
return self::status_error($code);
|
||||
}
|
||||
if (!preg_match('#^https://github\.com/' . preg_quote($repo, '#') . '/releases/tag/([^/?\#]+)$#i', $location, $match)) {
|
||||
// No releases yet: GitHub sends the releases list instead.
|
||||
return null;
|
||||
}
|
||||
|
||||
$tag = rawurldecode($match[1]);
|
||||
$version = preg_replace('/^v/i', '', $tag);
|
||||
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The release zip, by its usual name: {folder}-{version}.zip.
|
||||
$asset = null;
|
||||
$download = 'https://github.com/' . $repo . '/releases/download/' . rawurlencode($tag) . '/' . rawurlencode($folder . '-' . $version . '.zip');
|
||||
$head = wp_safe_remote_head($download, array('timeout' => 10, 'redirection' => 0));
|
||||
if (is_wp_error($head)) {
|
||||
return $head;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code($head);
|
||||
if ($code >= 300 && $code < 400) {
|
||||
$asset = array('public' => $download, 'api' => '');
|
||||
} elseif (404 !== $code) {
|
||||
return self::status_error($code);
|
||||
}
|
||||
|
||||
$release = array(
|
||||
'tag' => $tag,
|
||||
'version' => $version,
|
||||
'url' => $location,
|
||||
'published' => '',
|
||||
'notes' => '',
|
||||
'asset' => $asset,
|
||||
'zipball' => 'https://github.com/' . $repo . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
|
||||
);
|
||||
$file = wp_safe_remote_get('https://raw.githubusercontent.com/' . $repo . '/' . rawurlencode($tag) . '/' . rawurlencode($main), array(
|
||||
'timeout' => 10,
|
||||
'headers' => array('Range' => 'bytes=0-8191'),
|
||||
));
|
||||
$body = !is_wp_error($file) && in_array((int) wp_remote_retrieve_response_code($file), array(200, 206), true) ? (string) wp_remote_retrieve_body($file) : '';
|
||||
return $release + self::requirements($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest release from the GitHub API, with a token (private repositories).
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param string $folder Plugin folder.
|
||||
* @param string $main Main file name.
|
||||
* @return array|null|WP_Error
|
||||
*/
|
||||
private static function fetch_api($repo, $folder, $main) {
|
||||
// The newest release that is not a draft or pre-release.
|
||||
$response = self::api_get($repo, '/releases/latest');
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
if (404 === $code) {
|
||||
// No releases yet, or a private repository without a token.
|
||||
return null;
|
||||
}
|
||||
if (200 !== $code) {
|
||||
return self::status_error($code);
|
||||
}
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$tag = is_array($data) && isset($data['tag_name']) ? (string) $data['tag_name'] : '';
|
||||
$version = preg_replace('/^v/i', '', $tag);
|
||||
// Numbers only: anything else is not a finished version.
|
||||
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$release = array(
|
||||
'tag' => $tag,
|
||||
'version' => $version,
|
||||
'url' => isset($data['html_url']) ? (string) $data['html_url'] : 'https://github.com/' . $repo . '/releases',
|
||||
'published' => isset($data['published_at']) ? (string) $data['published_at'] : '',
|
||||
'notes' => isset($data['body']) ? substr((string) $data['body'], 0, 20000) : '',
|
||||
'asset' => self::pick_asset($repo, $folder, isset($data['assets']) && is_array($data['assets']) ? $data['assets'] : array()),
|
||||
'zipball' => 'https://api.github.com/repos/' . $repo . '/zipball/' . rawurlencode($tag),
|
||||
);
|
||||
$file = self::api_get($repo, '/contents/' . rawurlencode($main) . '?ref=' . rawurlencode($tag), 'application/vnd.github.raw+json');
|
||||
$body = !is_wp_error($file) && 200 === (int) wp_remote_retrieve_response_code($file) ? (string) wp_remote_retrieve_body($file) : '';
|
||||
return $release + self::requirements($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The release zip for a plugin folder: a .zip asset whose name starts
|
||||
* with the folder name (never the "wordpress-org-…" build).
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param string $folder Plugin folder.
|
||||
* @param array $assets Release assets.
|
||||
* @return array{public:string,api:string}|null Download addresses.
|
||||
*/
|
||||
private static function pick_asset($repo, $folder, array $assets) {
|
||||
foreach ($assets as $asset) {
|
||||
$name = isset($asset['name']) ? (string) $asset['name'] : '';
|
||||
if (0 !== stripos($name, $folder) || '.zip' !== strtolower(substr($name, -4))) {
|
||||
continue;
|
||||
}
|
||||
$public = isset($asset['browser_download_url']) ? (string) $asset['browser_download_url'] : '';
|
||||
$api = isset($asset['id']) ? 'https://api.github.com/repos/' . $repo . '/releases/assets/' . (int) $asset['id'] : '';
|
||||
if (0 === strpos($public, 'https://github.com/' . $repo . '/releases/download/')) {
|
||||
return array('public' => $public, 'api' => $api);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Requires at least" and "Requires PHP" of the released main file, so
|
||||
* WordPress does not offer an update the site cannot run.
|
||||
*
|
||||
* @param string $file Start of the released main file ('' when unknown).
|
||||
* @return array{requires:string,requires_php:string}
|
||||
*/
|
||||
private static function requirements($file) {
|
||||
$found = array('requires' => '', 'requires_php' => '');
|
||||
$head = substr((string) $file, 0, 8192);
|
||||
foreach (array('requires' => 'Requires at least', 'requires_php' => 'Requires PHP') as $key => $header) {
|
||||
if (preg_match('/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote($header, '/') . ':(.*)$/mi', $head, $match)) {
|
||||
$value = trim(preg_replace('/\s*(?:\*\/|\?>).*/', '', $match[1]));
|
||||
if (preg_match('/^[0-9]+(\.[0-9]+)*$/', $value)) {
|
||||
$found[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download address WordPress installs from.
|
||||
*
|
||||
* @param string $repo owner/repo.
|
||||
* @param array $release Release.
|
||||
* @param bool $asset_only Install the release asset only.
|
||||
* @return string '' when the release has nothing to install.
|
||||
*/
|
||||
private static function package($repo, array $release, $asset_only) {
|
||||
$private = '' !== self::token($repo);
|
||||
if (!empty($release['asset'])) {
|
||||
// Assets of private repositories download through the API (see private_download()).
|
||||
return $private && $release['asset']['api'] ? $release['asset']['api'] : $release['asset']['public'];
|
||||
}
|
||||
return $asset_only ? '' : (string) $release['zipball'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether WordPress.org offers updates for a plugin.
|
||||
*
|
||||
* @param object $transient update_plugins.
|
||||
* @param string $file Plugin file.
|
||||
* @return bool
|
||||
*/
|
||||
private static function on_wordpress_org($transient, $file) {
|
||||
foreach (array('response', 'no_update') as $list) {
|
||||
if (isset($transient->{$list}[$file])) {
|
||||
$item = (object) $transient->{$list}[$file];
|
||||
$id = isset($item->id) ? (string) $item->id : '';
|
||||
if (0 !== strpos($id, self::ID_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell WordPress about GitHub releases when it saves its update check.
|
||||
*
|
||||
* @param mixed $transient update_plugins.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function add_updates($transient) {
|
||||
if (!is_object($transient)) {
|
||||
return $transient;
|
||||
}
|
||||
/**
|
||||
* Whether plugins also on WordPress.org take each version from GitHub
|
||||
* as soon as it is released, instead of waiting for WordPress.org.
|
||||
*
|
||||
* @param bool $early Default false.
|
||||
*/
|
||||
$early = (bool) apply_filters('wpallstars_github_updater_early', false);
|
||||
|
||||
foreach (self::plugins() as $file => $plugin) {
|
||||
if (!$early && self::on_wordpress_org($transient, $file)) {
|
||||
continue;
|
||||
}
|
||||
$folder = dirname($file);
|
||||
$release = self::release($plugin['repo'], $folder, basename($file));
|
||||
$current = isset($transient->checked[$file]) ? (string) $transient->checked[$file] : $plugin['version'];
|
||||
if (!$release) {
|
||||
// Keep WordPress.org's answer, or say nothing.
|
||||
continue;
|
||||
}
|
||||
$dot_org = isset($transient->response[$file]->new_version) ? (string) $transient->response[$file]->new_version : '';
|
||||
if ('' !== $dot_org && 0 !== strpos((string) ($transient->response[$file]->id ?? ''), self::ID_PREFIX) && version_compare($dot_org, $release['version'], '>=')) {
|
||||
// WordPress.org already offers this version or a newer one.
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = (object) array(
|
||||
'id' => self::ID_PREFIX . $plugin['repo'],
|
||||
'slug' => $folder,
|
||||
'plugin' => $file,
|
||||
'new_version' => $release['version'],
|
||||
'url' => 'https://github.com/' . $plugin['repo'],
|
||||
'package' => self::package($plugin['repo'], $release, $plugin['asset_only']),
|
||||
'requires' => $release['requires'],
|
||||
'requires_php' => $release['requires_php'],
|
||||
'tested' => '',
|
||||
'icons' => array(),
|
||||
'banners' => array(),
|
||||
'banners_rtl' => array(),
|
||||
'compatibility' => new stdClass(),
|
||||
);
|
||||
|
||||
if (!is_array($transient->response ?? null)) {
|
||||
$transient->response = array();
|
||||
}
|
||||
if (!is_array($transient->no_update ?? null)) {
|
||||
$transient->no_update = array();
|
||||
}
|
||||
if ('' === $item->package && self::on_wordpress_org($transient, $file)) {
|
||||
// Nothing to install from GitHub: keep WordPress.org's answer.
|
||||
continue;
|
||||
}
|
||||
unset($transient->response[$file], $transient->no_update[$file]);
|
||||
if ('' !== $item->package && version_compare($release['version'], $current, '>')) {
|
||||
$transient->response[$file] = $item;
|
||||
} else {
|
||||
// Listed as up to date, so the Plugins screen offers auto-updates.
|
||||
$transient->no_update[$file] = $item;
|
||||
}
|
||||
}
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin a "View details" request is about, if it is ours to answer.
|
||||
*
|
||||
* @param string $slug Plugin folder.
|
||||
* @return string Plugin file, or ''.
|
||||
*/
|
||||
private static function file_for_slug($slug) {
|
||||
$updates = get_site_transient('update_plugins');
|
||||
foreach (self::plugins() as $file => $plugin) {
|
||||
if (dirname($file) !== $slug) {
|
||||
continue;
|
||||
}
|
||||
foreach (array('response', 'no_update') as $list) {
|
||||
if (is_object($updates) && isset($updates->{$list}[$file]->id) && 0 === strpos((string) $updates->{$list}[$file]->id, self::ID_PREFIX)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* "View details" for plugins updated from GitHub.
|
||||
*
|
||||
* @param false|object|array $result Result so far.
|
||||
* @param string $action plugins_api action.
|
||||
* @param object $args Request arguments.
|
||||
* @return false|object|array
|
||||
*/
|
||||
public static function plugin_information($result, $action, $args) {
|
||||
if ('plugin_information' !== $action || !is_object($args) || empty($args->slug)) {
|
||||
return $result;
|
||||
}
|
||||
$file = self::file_for_slug((string) $args->slug);
|
||||
if ('' === $file) {
|
||||
return $result;
|
||||
}
|
||||
$plugins = self::plugins();
|
||||
$plugin = $plugins[$file];
|
||||
$release = self::release($plugin['repo'], dirname($file), basename($file));
|
||||
$data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, true);
|
||||
|
||||
$sections = array(
|
||||
'description' => wpautop(esc_html(isset($data['Description']) ? wp_strip_all_tags($data['Description']) : '')),
|
||||
);
|
||||
if ($release) {
|
||||
$sections['changelog'] = '<h4>' . esc_html($release['version']) . '</h4>'
|
||||
. ('' !== trim($release['notes']) ? self::notes_html($release['notes']) : '')
|
||||
. sprintf('<p><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a></p>', esc_url($release['url']), esc_html__('Release notes on GitHub', 'wp-plugin-starter-template'));
|
||||
}
|
||||
|
||||
return (object) array(
|
||||
'name' => $plugin['name'],
|
||||
'slug' => dirname($file),
|
||||
'version' => $release ? $release['version'] : $plugin['version'],
|
||||
'author' => isset($data['Author']) ? $data['Author'] : '',
|
||||
'homepage' => 'https://github.com/' . $plugin['repo'],
|
||||
'requires' => $release ? $release['requires'] : '',
|
||||
'requires_php' => $release ? $release['requires_php'] : '',
|
||||
'last_updated' => $release ? $release['published'] : '',
|
||||
'download_link' => $release ? self::package($plugin['repo'], $release, $plugin['asset_only']) : '',
|
||||
'sections' => $sections,
|
||||
'banners' => array(),
|
||||
'external' => true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release notes (Markdown) as simple HTML: headings, lists, paragraphs,
|
||||
* bold and code. Everything is escaped first.
|
||||
*
|
||||
* @param string $markdown Release notes.
|
||||
* @return string
|
||||
*/
|
||||
private static function notes_html($markdown) {
|
||||
$html = '';
|
||||
$list = false;
|
||||
foreach (preg_split('/\r\n|\r|\n/', (string) $markdown) as $line) {
|
||||
$line = rtrim($line);
|
||||
$text = esc_html(ltrim(preg_replace('/^(#{1,6}|[-*+])\s+/', '', trim($line))));
|
||||
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
|
||||
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
|
||||
$item = (bool) preg_match('/^\s*[-*+]\s+/', $line);
|
||||
if ($list && !$item) {
|
||||
$html .= '</ul>';
|
||||
$list = false;
|
||||
}
|
||||
if ('' === trim($line)) {
|
||||
continue;
|
||||
}
|
||||
if ($item) {
|
||||
if (!$list) {
|
||||
$html .= '<ul>';
|
||||
$list = true;
|
||||
}
|
||||
$html .= '<li>' . $text . '</li>';
|
||||
} elseif (preg_match('/^#{1,6}\s/', $line)) {
|
||||
$html .= '<h4>' . $text . '</h4>';
|
||||
} else {
|
||||
$html .= '<p>' . $text . '</p>';
|
||||
}
|
||||
}
|
||||
return $html . ($list ? '</ul>' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads from private repositories: ask the API with the token, then
|
||||
* fetch the file from where GitHub sends us without it.
|
||||
*
|
||||
* @param mixed $reply False to let WordPress download.
|
||||
* @param string $package Download address.
|
||||
* @param WP_Upgrader $upgrader Upgrader.
|
||||
* @return mixed File path, WP_Error or $reply.
|
||||
*/
|
||||
public static function private_download($reply, $package, $upgrader) {
|
||||
if (false !== $reply || !is_string($package) || !preg_match('#^https://api\.github\.com/repos/([^/]+/[^/]+)/(?:releases/assets/[0-9]+|zipball/[^/?]+)$#', $package, $match)) {
|
||||
return $reply;
|
||||
}
|
||||
$repo = $match[1];
|
||||
$known = false;
|
||||
foreach (self::plugins() as $plugin) {
|
||||
$known = $known || $plugin['repo'] === $repo;
|
||||
}
|
||||
$token = self::token($repo);
|
||||
if (!$known || '' === $token) {
|
||||
return $reply;
|
||||
}
|
||||
|
||||
$response = wp_safe_remote_get($package, array(
|
||||
'timeout' => 30,
|
||||
'redirection' => 0,
|
||||
'headers' => array(
|
||||
'Accept' => false !== strpos($package, '/releases/assets/') ? 'application/octet-stream' : 'application/vnd.github+json',
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
),
|
||||
));
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
$location = (string) wp_remote_retrieve_header($response, 'location');
|
||||
if ('' === $location || !in_array((int) wp_remote_retrieve_response_code($response), array(301, 302, 303, 307, 308), true)) {
|
||||
return new WP_Error('wpallstars_github_download', __('GitHub did not give a download address. Check the token’s access to the repository.', 'wp-plugin-starter-template'));
|
||||
}
|
||||
// A signed, short-lived address: no token needed (or wanted) there.
|
||||
return download_url($location, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a plugin in its folder when the zip's top folder has another name
|
||||
* (GitHub's source zips are named owner-repo-commit).
|
||||
*
|
||||
* @param string|WP_Error $source Unpacked folder.
|
||||
* @param string $remote_source Folder it was unpacked in.
|
||||
* @param WP_Upgrader $upgrader Upgrader.
|
||||
* @param array $hook_extra Context.
|
||||
* @return string|WP_Error
|
||||
*/
|
||||
public static function fix_folder($source, $remote_source, $upgrader, $hook_extra = array()) {
|
||||
global $wp_filesystem;
|
||||
if (is_wp_error($source) || empty($hook_extra['plugin']) || !$wp_filesystem) {
|
||||
return $source;
|
||||
}
|
||||
$file = (string) $hook_extra['plugin'];
|
||||
if (!isset(self::plugins()[$file])) {
|
||||
return $source;
|
||||
}
|
||||
$folder = dirname($file);
|
||||
if (basename(untrailingslashit($source)) === $folder) {
|
||||
return $source;
|
||||
}
|
||||
$target = trailingslashit($remote_source) . $folder . '/';
|
||||
$from = untrailingslashit($source);
|
||||
if ($from === untrailingslashit($remote_source)) {
|
||||
// A zip without a top folder: move its files aside, then into the folder.
|
||||
$from = untrailingslashit($remote_source) . '-github-updater';
|
||||
if (!$wp_filesystem->move(untrailingslashit($remote_source), $from, true) || !$wp_filesystem->mkdir(untrailingslashit($remote_source), FS_CHMOD_DIR)) {
|
||||
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
|
||||
}
|
||||
}
|
||||
if (!$wp_filesystem->move($from, untrailingslashit($target), true)) {
|
||||
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
|
||||
}
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared GitHub updater: registers this copy.
|
||||
*
|
||||
* Every plugin made from the starter carries a copy of includes/github-updater/.
|
||||
* Each copy registers its version here when its plugin loads; on
|
||||
* plugins_loaded only the newest copy loads, once, and serves every installed
|
||||
* plugin with a `GitHub Plugin URI` header. So one update check covers all of
|
||||
* them, however many carry a copy.
|
||||
*
|
||||
* The WordPress.org build leaves this folder out (.distignore-wporg): plugins
|
||||
* hosted there may not install or update code from anywhere else.
|
||||
*
|
||||
* The starter holds the master copy. Change it there, raise the version
|
||||
* below with every change, then copy it to each plugin (scripts/sync-core.sh).
|
||||
* Plugins change what it does only through its filters, never by calling the
|
||||
* class: another plugin's newer or older copy may be the one that runs.
|
||||
*
|
||||
* @package WPStarter
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($GLOBALS['wpallstars_github_updater']) || !is_array($GLOBALS['wpallstars_github_updater'])) {
|
||||
$GLOBALS['wpallstars_github_updater'] = array();
|
||||
}
|
||||
// Version of this copy => its class file.
|
||||
$GLOBALS['wpallstars_github_updater']['1.0.0'] = __DIR__ . '/class-wpallstars-github-updater.php';
|
||||
|
||||
if (!function_exists('wpallstars_github_updater_load')) {
|
||||
/**
|
||||
* Load the newest registered copy of the updater (plugins_loaded).
|
||||
*/
|
||||
function wpallstars_github_updater_load() {
|
||||
if (class_exists('WPAllStars_GitHub_Updater', false) || empty($GLOBALS['wpallstars_github_updater'])) {
|
||||
return;
|
||||
}
|
||||
$copies = (array) $GLOBALS['wpallstars_github_updater'];
|
||||
uksort($copies, 'version_compare');
|
||||
$file = (string) end($copies);
|
||||
if (is_readable($file)) {
|
||||
require_once $file;
|
||||
WPAllStars_GitHub_Updater::load();
|
||||
}
|
||||
}
|
||||
// Every plugin's main file has run by then, so every copy is registered.
|
||||
add_action('plugins_loaded', 'wpallstars_github_updater_load', 1);
|
||||
}
|
||||
Reference in new issue
Block a user