v1.2.1
This commit is contained in:
parent
7d3fc1969d
commit
3a25585090
42 changed files with 1124 additions and 711 deletions
|
|
@ -1,5 +1,44 @@
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.2.1 =
|
||||||
|
|
||||||
|
_Release date: 2026-08-17_
|
||||||
|
|
||||||
|
**Highlights**
|
||||||
|
|
||||||
|
* Automatic updates are now delivered through the Manager (by ROBOTSTXT) plugin; the built-in self-updater has been removed
|
||||||
|
* A dismissible notice on the Plugins page (and a permanent one on the plugin Settings page) recommends installing Manager when it is not active
|
||||||
|
* Minimum WordPress lowered to 4.2, the real lowest version the code runs on (verified with WP-Compat)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
* Removed the built-in self-updater (`class-robotstxt-updater.php` and `update.json`); updates are now handled by the [Manager (by ROBOTSTXT)](https://www.robotstxt.software/plugins/robotstxt-manager/) plugin
|
||||||
|
* New admin notice when the Manager plugin is not installed and active: dismissible on the Plugins page, permanent on the plugin Settings page, both linking to the Manager download page
|
||||||
|
* Minimum WordPress lowered from 6.8 to 4.2 — the real lowest compatible version confirmed by a WP-Compat scan (`wp_delete_file()`, available since WordPress 4.2, is the oldest API used)
|
||||||
|
* Plugin URI and Update URI now point to https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
|
||||||
|
* Author URI updated to https://www.robotstxt.software/
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
|
||||||
|
* league/commonmark updated from 2.9.0 to 2.10.0 via `composer update` (no known CVEs; `composer audit` clean)
|
||||||
|
|
||||||
|
**Compatibility**
|
||||||
|
|
||||||
|
* WordPress: 4.2 - 7.1
|
||||||
|
* PHP: 8.0 - 8.5 (real minimum confirmed by PHPCompatibility 5.6-8.5 full-range scan)
|
||||||
|
|
||||||
|
**Translations**
|
||||||
|
|
||||||
|
* Spanish (es_ES) and Catalan (ca): 10 strings added that were missing since 1.2.0, plus the new Manager notice string
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
* PHP Coding Standards: PHPCS with WordPress-Core, WordPress-Docs, WordPress-Extra — 0 errors
|
||||||
|
* PHPStan: level 9, 0 errors
|
||||||
|
* PHPCompatibility: PHP 8.0-8.5 validated (full-range scan 5.6-8.5)
|
||||||
|
* WP-Compat: WordPress 4.2 floor validated
|
||||||
|
* PHPUnit: plugin header tests pass
|
||||||
|
|
||||||
= 1.2.0 =
|
= 1.2.0 =
|
||||||
|
|
||||||
_Release date: 2026-08-07_
|
_Release date: 2026-08-07_
|
||||||
|
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Generic JSON-based updater for ROBOTSTXT plugins.
|
|
||||||
*
|
|
||||||
* This file is designed to be copied to any ROBOTSTXT plugin.
|
|
||||||
* It auto-configures itself by reading the plugin headers.
|
|
||||||
*
|
|
||||||
* @package ROBOTSTXT
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
|
||||||
/**
|
|
||||||
* Class Robotstxt_Updater
|
|
||||||
*
|
|
||||||
* Generic updater that works with any plugin.
|
|
||||||
* Reads plugin headers and constructs update URL automatically.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class Robotstxt_Updater {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin file path.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private string $plugin_file_path;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin basename (e.g., 'my-plugin/my-plugin.php').
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private string $plugin_basename;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin slug (directory name).
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private string $plugin_slug;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remote JSON URL.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private string $json_url;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache key.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private string $cache_key;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin headers.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
private array $plugin_data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the updater.
|
|
||||||
*
|
|
||||||
* Usage in your main plugin file:
|
|
||||||
* require_once __DIR__ . '/class-robotstxt-updater.php';
|
|
||||||
* Robotstxt_Updater::init( __FILE__ );
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param string $plugin_file_path Absolute path to the main plugin file.
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public static function init( string $plugin_file_path ): void {
|
|
||||||
$instance = new self( $plugin_file_path );
|
|
||||||
$instance->register();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param string $plugin_file_path Absolute path to the main plugin file.
|
|
||||||
*/
|
|
||||||
private function __construct( string $plugin_file_path ) {
|
|
||||||
$this->plugin_file_path = $plugin_file_path;
|
|
||||||
$this->plugin_basename = plugin_basename( $plugin_file_path );
|
|
||||||
$this->plugin_slug = dirname( $this->plugin_basename );
|
|
||||||
$this->plugin_data = $this->get_plugin_data();
|
|
||||||
$this->json_url = $this->build_json_url();
|
|
||||||
$this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register WordPress hooks.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register(): void {
|
|
||||||
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
|
|
||||||
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
|
|
||||||
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
|
|
||||||
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get plugin headers.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return array<string, mixed> Plugin data.
|
|
||||||
*/
|
|
||||||
private function get_plugin_data(): array {
|
|
||||||
if ( ! function_exists( 'get_plugin_data' ) ) {
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
return get_plugin_data( $this->plugin_file_path, false, false );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build JSON URL from plugin headers.
|
|
||||||
*
|
|
||||||
* Tries to use "Gitea Plugin URI" header to construct the URL.
|
|
||||||
* Falls back to Plugin URI if Gitea URI is not available.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return string JSON URL.
|
|
||||||
*/
|
|
||||||
private function build_json_url(): string {
|
|
||||||
$gitea_uri = isset( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] )
|
|
||||||
? $this->plugin_data['Gitea Plugin URI']
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if ( '' !== $gitea_uri ) {
|
|
||||||
if ( str_starts_with( $gitea_uri, 'http' ) ) {
|
|
||||||
return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) {
|
|
||||||
return 'https://git.robotstxt.es/' . $gitea_uri . '/raw/branch/main/update.json';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] )
|
|
||||||
? $this->plugin_data['PluginURI']
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if ( '' !== $plugin_uri && str_contains( $plugin_uri, 'git.robotstxt.es' ) ) {
|
|
||||||
return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://git.robotstxt.es/ROBOTSTXT/' . $this->plugin_slug . '/raw/branch/main/update.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject update info into WP's plugin update transient.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param mixed $transient The update_plugins transient value.
|
|
||||||
* @return mixed The modified transient.
|
|
||||||
*/
|
|
||||||
public function inject_update_info( $transient ) {
|
|
||||||
if ( ! ( $transient instanceof stdClass ) ) {
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! isset( $transient->checked ) || ! is_array( $transient->checked ) ) {
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! isset( $transient->checked[ $this->plugin_basename ] )
|
|
||||||
|| ! is_string( $transient->checked[ $this->plugin_basename ] )
|
|
||||||
) {
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
$current_version = $transient->checked[ $this->plugin_basename ];
|
|
||||||
$remote = $this->get_remote_data();
|
|
||||||
|
|
||||||
$remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : '';
|
|
||||||
$download_url = isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : '';
|
|
||||||
|
|
||||||
if ( '' === $remote_version || '' === $download_url ) {
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! $this->is_compatible( $remote ) ) {
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( version_compare( $remote_version, $current_version, '>' ) ) {
|
|
||||||
$plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] )
|
|
||||||
? $this->plugin_data['PluginURI']
|
|
||||||
: '';
|
|
||||||
|
|
||||||
$update = (object) array(
|
|
||||||
'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug,
|
|
||||||
'plugin' => $this->plugin_basename,
|
|
||||||
'new_version' => $remote_version,
|
|
||||||
'url' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri,
|
|
||||||
'package' => $download_url,
|
|
||||||
'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '',
|
|
||||||
'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '',
|
|
||||||
'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
|
|
||||||
$transient->response = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
$transient->response[ $this->plugin_basename ] = $update;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $transient;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provide "View details" modal content.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param false|object $result The result object or false if no result yet.
|
|
||||||
* @param string $action The type of information being requested.
|
|
||||||
* @param object $args Plugin API arguments.
|
|
||||||
* @return false|object The plugin information object or false.
|
|
||||||
*/
|
|
||||||
public function provide_plugin_details( $result, string $action, object $args ) {
|
|
||||||
if ( 'plugin_information' !== $action ) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! isset( $args->slug ) || ! is_string( $args->slug ) || $args->slug !== $this->plugin_slug ) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remote = $this->get_remote_data();
|
|
||||||
$remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : '';
|
|
||||||
|
|
||||||
if ( '' === $remote_version ) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$plugin_name = isset( $this->plugin_data['Name'] ) && is_string( $this->plugin_data['Name'] )
|
|
||||||
? $this->plugin_data['Name']
|
|
||||||
: $this->plugin_slug;
|
|
||||||
$plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] )
|
|
||||||
? $this->plugin_data['PluginURI']
|
|
||||||
: '';
|
|
||||||
$description = isset( $this->plugin_data['Description'] ) && is_string( $this->plugin_data['Description'] )
|
|
||||||
? $this->plugin_data['Description']
|
|
||||||
: '';
|
|
||||||
$author = isset( $this->plugin_data['Author'] ) && is_string( $this->plugin_data['Author'] )
|
|
||||||
? $this->plugin_data['Author']
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (object) array(
|
|
||||||
'name' => isset( $remote['name'] ) && is_string( $remote['name'] ) ? $remote['name'] : $plugin_name,
|
|
||||||
'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug,
|
|
||||||
'version' => $remote_version,
|
|
||||||
'author' => isset( $remote['author'] ) && is_string( $remote['author'] ) ? $remote['author'] : $author,
|
|
||||||
'homepage' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri,
|
|
||||||
'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '',
|
|
||||||
'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '',
|
|
||||||
'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '',
|
|
||||||
'sections' => array(
|
|
||||||
'description' => isset( $remote['description'] ) && is_string( $remote['description'] ) ? $remote['description'] : $description,
|
|
||||||
'changelog' => isset( $remote['changelog'] ) && is_string( $remote['changelog'] ) ? $remote['changelog'] : '',
|
|
||||||
),
|
|
||||||
'download_link' => isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get remote data with caching and HMAC signature verification.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return array<string, mixed> Remote data.
|
|
||||||
*/
|
|
||||||
private function get_remote_data(): array {
|
|
||||||
$cached = get_site_transient( $this->cache_key );
|
|
||||||
|
|
||||||
if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
|
|
||||||
if ( is_array( $cached )
|
|
||||||
&& isset( $cached['signature'], $cached['data'] )
|
|
||||||
&& is_string( $cached['signature'] )
|
|
||||||
&& is_array( $cached['data'] )
|
|
||||||
) {
|
|
||||||
$expected_sig = hash_hmac(
|
|
||||||
'sha256',
|
|
||||||
$this->cache_key . wp_json_encode( $cached['data'] ),
|
|
||||||
AUTH_SALT
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( hash_equals( $expected_sig, $cached['signature'] ) ) {
|
|
||||||
return $this->normalize_array( $cached['data'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
delete_site_transient( $this->cache_key );
|
|
||||||
$cached = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( false === $cached ) {
|
|
||||||
$remote = $this->fetch_json();
|
|
||||||
|
|
||||||
if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
|
|
||||||
$payload = array(
|
|
||||||
'data' => $remote,
|
|
||||||
'timestamp' => time(),
|
|
||||||
'signature' => hash_hmac(
|
|
||||||
'sha256',
|
|
||||||
$this->cache_key . wp_json_encode( $remote ),
|
|
||||||
AUTH_SALT
|
|
||||||
),
|
|
||||||
);
|
|
||||||
set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS );
|
|
||||||
} else {
|
|
||||||
set_site_transient( $this->cache_key, $remote, 6 * HOUR_IN_SECONDS );
|
|
||||||
}
|
|
||||||
|
|
||||||
return $remote;
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_array( $cached ) ? $this->normalize_array( $cached ) : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch JSON from remote URL.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return array<string, mixed> Decoded JSON data.
|
|
||||||
*/
|
|
||||||
private function fetch_json(): array {
|
|
||||||
$response = wp_remote_get(
|
|
||||||
$this->json_url,
|
|
||||||
array(
|
|
||||||
'timeout' => 10,
|
|
||||||
'headers' => array(
|
|
||||||
'Accept' => 'application/json',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
if ( $code < 200 || $code >= 300 ) {
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = wp_remote_retrieve_body( $response );
|
|
||||||
$data = json_decode( $body, true );
|
|
||||||
|
|
||||||
return is_array( $data ) ? $data : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check compatibility with current environment.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $remote Remote data.
|
|
||||||
* @return bool True if compatible.
|
|
||||||
*/
|
|
||||||
private function is_compatible( array $remote ): bool {
|
|
||||||
if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) {
|
|
||||||
if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) {
|
|
||||||
if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle manual cache clear via URL parameter.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function handle_cache_clear(): void {
|
|
||||||
$clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW );
|
|
||||||
if ( null === $clear_cache ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW );
|
|
||||||
$nonce = ( null !== $nonce_raw && is_string( $nonce_raw ) )
|
|
||||||
? sanitize_text_field( wp_unslash( $nonce_raw ) )
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) {
|
|
||||||
wp_die( esc_html__( 'Security check failed.', 'robotstxt-documentation-markdown' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! current_user_can( 'update_plugins' ) ) {
|
|
||||||
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-documentation-markdown' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->clear_cache();
|
|
||||||
wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) );
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild an array guaranteeing string keys for PHPStan type safety.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @param array<mixed, mixed> $data Raw array from transient or API.
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normalize_array( array $data ): array {
|
|
||||||
$result = array();
|
|
||||||
foreach ( $data as $k => $v ) {
|
|
||||||
if ( is_string( $k ) ) {
|
|
||||||
$result[ $k ] = $v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear update cache.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function clear_cache(): void {
|
|
||||||
delete_site_transient( $this->cache_key );
|
|
||||||
delete_site_transient( 'update_plugins' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
120
readme.txt
120
readme.txt
|
|
@ -1,10 +1,10 @@
|
||||||
=== Documentation Markdown (by ROBOTSTXT) ===
|
=== Documentation Markdown (by ROBOTSTXT) ===
|
||||||
Contributors: robotstxt
|
Contributors: robotstxt, javiercasares
|
||||||
Tags: github, documentation, markdown, sync, automation
|
Tags: github, documentation, markdown, sync, automation
|
||||||
Requires at least: 6.8
|
Requires at least: 4.2
|
||||||
Tested up to: 7.1
|
Tested up to: 7.1
|
||||||
Requires PHP: 8.0
|
Requires PHP: 8.0
|
||||||
Stable tag: 1.2.0
|
Stable tag: 1.2.1
|
||||||
License: GPLv3 or later
|
License: GPLv3 or later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
|
@ -45,10 +45,14 @@ Synchronize Markdown documentation from GitHub repositories to WordPress pages a
|
||||||
= Requirements =
|
= Requirements =
|
||||||
|
|
||||||
* PHP 8.0 or higher
|
* PHP 8.0 or higher
|
||||||
* WordPress 6.8 or higher
|
* WordPress 4.2 or higher
|
||||||
* GitHub Personal Access Token (free, for accessing repositories)
|
* GitHub Personal Access Token (free, for accessing repositories)
|
||||||
* Composer (for production build with dependencies)
|
* Composer (for production build with dependencies)
|
||||||
|
|
||||||
|
= Updates =
|
||||||
|
|
||||||
|
Automatic updates for this plugin are delivered through the [Manager (by ROBOTSTXT)](https://www.robotstxt.software/plugins/robotstxt-manager/) plugin. Install and activate Manager to receive update notifications.
|
||||||
|
|
||||||
= Security =
|
= Security =
|
||||||
|
|
||||||
* GitHub tokens encrypted at rest (AES-256-CBC)
|
* GitHub tokens encrypted at rest (AES-256-CBC)
|
||||||
|
|
@ -178,12 +182,34 @@ Then go to Documentation → Settings, and you'll see a "Debug Tools" section at
|
||||||
|
|
||||||
== Compatibility ==
|
== Compatibility ==
|
||||||
|
|
||||||
* WordPress: 6.8 - 7.1
|
* WordPress: 4.2 - 7.1
|
||||||
* PHP: 8.0 - 8.5
|
* PHP: 8.0 - 8.5
|
||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/raw/branch/main/changelog.txt).
|
For the complete changelog, see [changelog.txt](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/).
|
||||||
|
|
||||||
|
= 1.2.1 - 2026-08-17 =
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
* Automatic updates are now delivered through the Manager (by ROBOTSTXT) plugin: the built-in self-updater (`class-robotstxt-updater.php` and `update.json`) has been removed
|
||||||
|
* When the Manager plugin is not active, a dismissible notice on the Plugins page and a permanent notice on the plugin Settings page recommend installing it
|
||||||
|
* Minimum WordPress lowered to 4.2 — the real lowest version the code runs on, verified with WP-Compat (`wp_delete_file()`, available since 4.2, is the oldest API used)
|
||||||
|
* Plugin URI and Update URI now point to https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
|
||||||
|
* league/commonmark updated to 2.10.0 (via `composer update`)
|
||||||
|
|
||||||
|
**Compatibility**
|
||||||
|
|
||||||
|
* WordPress: 4.2 - 7.1
|
||||||
|
* PHP: 8.0 - 8.5
|
||||||
|
|
||||||
|
**Translations**
|
||||||
|
|
||||||
|
* Spanish (es_ES) and Catalan (ca): 10 missing strings from 1.2.0 added, plus the new Manager notice
|
||||||
|
|
||||||
= 1.2.0 - 2026-08-07 =
|
= 1.2.0 - 2026-08-07 =
|
||||||
|
|
||||||
|
|
@ -246,93 +272,25 @@ For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTST
|
||||||
* PHPUnit: 17 plugin header tests added
|
* PHPUnit: 17 plugin header tests added
|
||||||
* PHPCS, PHPStan level 9, PHPUnit all pass with 0 errors
|
* PHPCS, PHPStan level 9, PHPUnit all pass with 0 errors
|
||||||
|
|
||||||
= 1.1.0 - 2026-03-28 =
|
|
||||||
|
|
||||||
**Security**
|
|
||||||
|
|
||||||
* Patched CVE-2026-33347 and CVE-2026-30838 (league/commonmark updated to 2.8.2)
|
|
||||||
|
|
||||||
**Changed**
|
|
||||||
|
|
||||||
* Access level changed from administrator (manage_options) to editor (edit_pages) — editors can now manage documentation mappings without requiring full admin access
|
|
||||||
|
|
||||||
**Fixed**
|
|
||||||
|
|
||||||
* PHPStan level 9 compliance: zero errors across all plugin files (down from 104)
|
|
||||||
* target_order (menu_order) field was in the UI but never saved or applied — now fully implemented
|
|
||||||
* Type-safety improvements for all WordPress API returns (get_option, get_post_meta, json_decode, size_format)
|
|
||||||
* Token decryption false return handled correctly
|
|
||||||
* Uninstall data cleanup narrows mixed option return before array access
|
|
||||||
|
|
||||||
**Compatibility**
|
|
||||||
|
|
||||||
* Verified compatible with WordPress 6.8 and 7.0
|
|
||||||
* Verified compatible with PHP 8.4.x
|
|
||||||
|
|
||||||
= 1.0.0 - 2026-01-26 =
|
|
||||||
|
|
||||||
**Initial Release**
|
|
||||||
|
|
||||||
* ✨ Core synchronization functionality
|
|
||||||
* 🔄 Automatic scheduled sync (hourly, twice daily, daily)
|
|
||||||
* ⚡ Manual on-demand sync
|
|
||||||
* 📝 Markdown to HTML conversion using CommonMark
|
|
||||||
* 🎯 Flexible file-to-content mapping system
|
|
||||||
* 🔐 Encrypted GitHub token storage
|
|
||||||
* 🌍 Full internationalization support
|
|
||||||
* 📚 Multi-repository support
|
|
||||||
* 🎨 Clean admin interface with status badges
|
|
||||||
* 🔧 Custom Post Type for mapping management
|
|
||||||
* 📦 Support for pages, posts, and custom post types
|
|
||||||
* 👤 Configurable post author and parent page
|
|
||||||
* 🔢 Page order (menu_order) support
|
|
||||||
* 🐛 Debug tools for troubleshooting
|
|
||||||
* 🔄 Cron job management and repair tools
|
|
||||||
* 🧹 Clean uninstall with optional data deletion
|
|
||||||
* ✅ WordPress Coding Standards compliant
|
|
||||||
* 🛡️ Security best practices (nonces, escaping, sanitization)
|
|
||||||
|
|
||||||
**Admin Features:**
|
|
||||||
* Settings page for GitHub configuration
|
|
||||||
* Mappings management interface
|
|
||||||
* Add/Edit mapping with validation
|
|
||||||
* Sync status monitoring
|
|
||||||
* Debug tools (when WP_DEBUG enabled)
|
|
||||||
- Test GitHub connection
|
|
||||||
- Test token validity
|
|
||||||
- View scheduled crons
|
|
||||||
- Run crons manually
|
|
||||||
- Fix cron schedules
|
|
||||||
- Clear plugin caches
|
|
||||||
|
|
||||||
**Developer Features:**
|
|
||||||
* Procedural PHP following KISS principles
|
|
||||||
* PHP 8.0+ modern features
|
|
||||||
* Complete PHPDoc documentation
|
|
||||||
* WordPress hooks and filters
|
|
||||||
* Extensible architecture
|
|
||||||
* PHPCS and PHPStan validated
|
|
||||||
|
|
||||||
== Upgrade Notice ==
|
== Upgrade Notice ==
|
||||||
|
|
||||||
= 1.0.0 =
|
= 1.2.1 =
|
||||||
Initial release of Documentation Markdown. Sync your GitHub Markdown files to WordPress automatically!
|
Automatic updates now require the Manager (by ROBOTSTXT) plugin. A notice with the download link is shown on the Plugins and Settings pages.
|
||||||
|
|
||||||
== Additional Information ==
|
== Additional Information ==
|
||||||
|
|
||||||
= Support =
|
= Support =
|
||||||
|
|
||||||
* **Documentation:** Comprehensive docs included in `/docs/` directory
|
* **Documentation:** [robotstxt.software](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/)
|
||||||
* **Repository:** [Report issues](https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/issues)
|
* **Updates:** [Manager (by ROBOTSTXT)](https://www.robotstxt.software/plugins/robotstxt-manager/)
|
||||||
* **Website:** [ROBOTSTXT.es](https://www.robotstxt.es/)
|
* **Website:** [ROBOTSTXT.software](https://www.robotstxt.software/)
|
||||||
* **Security:** robotstxt@robotstxt.es
|
* **Security:** robotstxt@robotstxt.es
|
||||||
|
|
||||||
= Contributing =
|
= Contributing =
|
||||||
|
|
||||||
We welcome contributions! Please visit our [Gitea repository](https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown) to:
|
We welcome contributions! Please visit our [website](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/) to:
|
||||||
* Report bugs
|
* Report bugs
|
||||||
* Suggest features
|
* Suggest features
|
||||||
* Submit pull requests
|
|
||||||
|
|
||||||
= Privacy =
|
= Privacy =
|
||||||
|
|
||||||
|
|
@ -340,7 +298,7 @@ This plugin does not collect or store any user data. The only external connectio
|
||||||
|
|
||||||
= Credits =
|
= Credits =
|
||||||
|
|
||||||
Developed by ROBOTSTXT with ❤️
|
Developed by [ROBOTSTXT](https://www.robotstxt.software/) with ❤️
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
* [league/commonmark](https://commonmark.thephpleague.com/) - Markdown parser and converter
|
* [league/commonmark](https://commonmark.thephpleague.com/) - Markdown parser and converter
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: Documentation Markdown (by ROBOTSTXT)
|
* Plugin Name: Documentation Markdown (by ROBOTSTXT)
|
||||||
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown
|
* Plugin URI: https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
|
||||||
* Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically.
|
* Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically.
|
||||||
* Version: 1.2.0
|
* Version: 1.2.1
|
||||||
* Requires at least: 6.8
|
* Requires at least: 4.2
|
||||||
* Requires PHP: 8.0
|
* Requires PHP: 8.0
|
||||||
* Security: robotstxt@robotstxt.es
|
* Security: robotstxt@robotstxt.es
|
||||||
* Author: ROBOTSTXT
|
* Author: ROBOTSTXT
|
||||||
* Author URI: https://www.robotstxt.es/
|
* Author URI: https://www.robotstxt.software/
|
||||||
* Text Domain: robotstxt-documentation-markdown
|
* Text Domain: robotstxt-documentation-markdown
|
||||||
* Domain Path: /languages
|
* Domain Path: /languages
|
||||||
* License: GPL-3.0-or-later
|
* License: GPL-3.0-or-later
|
||||||
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown
|
* Update URI: https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
|
||||||
* Primary Branch: main
|
|
||||||
*
|
*
|
||||||
* @package RobotsTxt\DocumentationMarkdown
|
* @package RobotsTxt\DocumentationMarkdown
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
|
|
@ -26,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define plugin constants.
|
// Define plugin constants.
|
||||||
define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.0' );
|
define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.1' );
|
||||||
define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ );
|
define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
@ -53,6 +52,7 @@ add_action( 'init', 'robotstxt_docmd_init' );
|
||||||
add_action( 'admin_menu', 'robotstxt_docmd_register_admin_menu' );
|
add_action( 'admin_menu', 'robotstxt_docmd_register_admin_menu' );
|
||||||
add_action( 'admin_enqueue_scripts', 'robotstxt_docmd_enqueue_admin_assets' );
|
add_action( 'admin_enqueue_scripts', 'robotstxt_docmd_enqueue_admin_assets' );
|
||||||
add_action( 'admin_init', 'robotstxt_docmd_handle_admin_actions' );
|
add_action( 'admin_init', 'robotstxt_docmd_handle_admin_actions' );
|
||||||
|
add_action( 'admin_notices', 'robotstxt_docmd_admin_notice_manager_plugin' );
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin activation
|
* Plugin activation
|
||||||
|
|
@ -1046,6 +1046,10 @@ function robotstxt_docmd_render_settings_page() {
|
||||||
|
|
||||||
<?php robotstxt_docmd_render_admin_notices(); ?>
|
<?php robotstxt_docmd_render_admin_notices(); ?>
|
||||||
|
|
||||||
|
<?php if ( ! robotstxt_docmd_is_manager_active() ) : ?>
|
||||||
|
<?php robotstxt_docmd_render_manager_notice( false ); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="">
|
<form method="post" action="">
|
||||||
<?php wp_nonce_field( 'robotstxt_docmd_save_settings', 'robotstxt_docmd_nonce' ); ?>
|
<?php wp_nonce_field( 'robotstxt_docmd_save_settings', 'robotstxt_docmd_nonce' ); ?>
|
||||||
|
|
||||||
|
|
@ -1671,6 +1675,65 @@ function robotstxt_docmd_debug_fix_crons() {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize ROBOTSTXT updater (auto-configures from plugin headers).
|
/**
|
||||||
require_once __DIR__ . '/class-robotstxt-updater.php';
|
* Check whether the Manager (by ROBOTSTXT) plugin is active
|
||||||
Robotstxt_Updater::init( __FILE__ );
|
*
|
||||||
|
* Updates for this plugin are delivered through the Manager plugin. This
|
||||||
|
* helper detects whether it is installed and active.
|
||||||
|
*
|
||||||
|
* @since 1.2.1
|
||||||
|
*
|
||||||
|
* @return bool True if Manager (by ROBOTSTXT) is active, false otherwise.
|
||||||
|
*/
|
||||||
|
function robotstxt_docmd_is_manager_active(): bool {
|
||||||
|
return defined( 'ROBOTSTXT_MANAGER_VERSION' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the Manager (by ROBOTSTXT) recommendation notice
|
||||||
|
*
|
||||||
|
* Shared markup for the plugins list page (dismissible) and the plugin
|
||||||
|
* settings page (not dismissible). Only rendered when Manager is not active.
|
||||||
|
*
|
||||||
|
* @since 1.2.1
|
||||||
|
*
|
||||||
|
* @param bool $dismissible Whether the notice should be dismissible.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function robotstxt_docmd_render_manager_notice( bool $dismissible ): void {
|
||||||
|
echo '<div class="notice notice-warning' . ( $dismissible ? ' is-dismissible' : '' ) . '"><p>';
|
||||||
|
echo wp_kses(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: Manager (by ROBOTSTXT) plugin URL. */
|
||||||
|
__( 'To receive automatic updates, the <a href="%s">Manager (by ROBOTSTXT)</a> plugin must be installed and active.', 'robotstxt-documentation-markdown' ),
|
||||||
|
esc_url( 'https://www.robotstxt.software/plugins/robotstxt-manager/' )
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'a' => array(
|
||||||
|
'href' => true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
echo '</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Manager (by ROBOTSTXT) notice on the plugins list page
|
||||||
|
*
|
||||||
|
* @since 1.2.1
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function robotstxt_docmd_admin_notice_manager_plugin(): void {
|
||||||
|
global $pagenow;
|
||||||
|
|
||||||
|
if ( 'plugins.php' !== $pagenow ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'edit_pages' ) || robotstxt_docmd_is_manager_active() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
robotstxt_docmd_render_manager_notice( true );
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* @package RobotsTxt\DocumentationMarkdown
|
* @package RobotsTxt\DocumentationMarkdown
|
||||||
* @author ROBOTSTXT
|
* @author ROBOTSTXT
|
||||||
* @license GPL-3.0-or-later
|
* @license GPL-3.0-or-later
|
||||||
* @link https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown
|
* @link https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
10
update.json
10
update.json
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "Documentation Markdown (by ROBOTSTXT)",
|
"name": "Documentation Markdown (by ROBOTSTXT)",
|
||||||
"slug": "robotstxt-documentation-markdown",
|
"slug": "robotstxt-documentation-markdown",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/releases/download/1.2.0/robotstxt-documentation-markdown-1.2.0.zip",
|
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/releases/download/1.2.1/robotstxt-documentation-markdown-1.2.1.zip",
|
||||||
"requires": "6.8",
|
"requires": "6.8",
|
||||||
"requires_php": "8.0",
|
"requires_php": "8.0",
|
||||||
"tested": "7.1",
|
"tested": "7.1",
|
||||||
|
|
@ -11,12 +11,10 @@
|
||||||
"author_profile": "https://www.robotstxt.es/",
|
"author_profile": "https://www.robotstxt.es/",
|
||||||
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown",
|
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown",
|
||||||
"description": "Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. Perfect for maintaining technical documentation, API references, knowledge bases, and more with version control.",
|
"description": "Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. Perfect for maintaining technical documentation, API references, knowledge bases, and more with version control.",
|
||||||
"changelog": "<h3>1.2.0 - 2026-08-07</h3><h4>Added</h4><ul><li><strong>Title from H1:</strong> synced post title taken from the first Markdown H1 (and stripped from the body)</li><li><strong>Internal link translation:</strong> repo-relative links rewritten to the matching WordPress permalink</li><li><strong>Image sideloading:</strong> repository images imported into the Media Library and referenced by attachment URL</li></ul><h4>Security</h4><ul><li>Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)</li><li>GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)</li><li>CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d</li></ul><h3>1.1.1 - 2026-06-08</h3><h4>Security</h4><ul><li>CommonMark raw HTML passthrough disabled; token option no longer autoloaded</li></ul><h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li><strong>Core:</strong> GitHub repository synchronization system</li><li><strong>Core:</strong> Markdown to HTML conversion using CommonMark</li><li><strong>Core:</strong> Encrypted GitHub token storage (AES-256-CBC)</li><li><strong>Feature:</strong> Flexible file-to-content mapping system</li><li><strong>Feature:</strong> Custom Post Type for mapping management</li><li><strong>Feature:</strong> Configurable sync frequency (manual, hourly, twice daily, daily)</li><li><strong>Feature:</strong> Manual on-demand synchronization</li><li><strong>Feature:</strong> Support for pages, posts, and custom post types</li><li><strong>Feature:</strong> Page order (menu_order) configuration</li><li><strong>Feature:</strong> Configurable post author and parent page</li><li><strong>Admin:</strong> Complete admin interface with status monitoring</li><li><strong>Admin:</strong> Settings page for GitHub configuration</li><li><strong>Admin:</strong> Mappings management interface</li><li><strong>Debug:</strong> Built-in debug tools (visible when WP_DEBUG enabled)</li><li><strong>Debug:</strong> Test GitHub connection and token validity</li><li><strong>Debug:</strong> View and manage scheduled cron jobs</li><li><strong>Debug:</strong> Fix/reschedule broken cron jobs</li><li><strong>Debug:</strong> Cache management tools</li><li><strong>Security:</strong> Complete input sanitization and output escaping</li><li><strong>Security:</strong> Nonce verification on all forms</li><li><strong>Security:</strong> Capability checks for admin actions</li><li><strong>i18n:</strong> Full internationalization support</li><li><strong>Quality:</strong> WordPress Coding Standards compliant</li></ul>",
|
"changelog": "",
|
||||||
"sections": {
|
"sections": {
|
||||||
"description": "<p><strong>Documentation Markdown</strong> is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.</p><h4>Key Features</h4><ul><li><strong>Automatic Synchronization:</strong> Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)</li><li><strong>Markdown to HTML:</strong> Convert GitHub Flavored Markdown to clean HTML using CommonMark</li><li><strong>Flexible Mapping:</strong> Map individual MD files to specific WordPress posts or pages</li><li><strong>Title from H1:</strong> Post titles are derived from the first Markdown H1 heading</li><li><strong>Link & Image Translation:</strong> Repo-relative links are rewritten to permalinks; images are sideloaded into the Media Library</li><li><strong>Secure:</strong> Encrypted GitHub token storage (HKDF-SHA256), full input validation & output escaping</li><li><strong>Translatable:</strong> Full internationalization support (i18n/l10n ready)</li><li><strong>Multi-Repository:</strong> Sync from multiple GitHub repos simultaneously</li><li><strong>Manual Sync:</strong> On-demand synchronization from admin interface</li><li><strong>Debug Tools:</strong> Built-in debugging tools (visible when WP_DEBUG is enabled)</li></ul><h4>Use Cases</h4><ul><li>API Documentation - Keep your API docs in sync between GitHub and WordPress</li><li>Technical Documentation - Maintain version-controlled technical docs</li><li>Knowledge Base - Build a knowledge base powered by GitHub</li><li>Blog Posts - Write blog posts in Markdown with Git workflow</li><li>Product Documentation - Sync product documentation from your repository</li></ul><h4>Requirements</h4><ul><li>PHP 8.0 or higher</li><li>WordPress 6.8 or higher</li><li>GitHub Personal Access Token (free)</li></ul>",
|
"description": "<p><strong>Documentation Markdown</strong> is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.</p><h4>Key Features</h4><ul><li><strong>Automatic Synchronization:</strong> Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)</li><li><strong>Markdown to HTML:</strong> Convert GitHub Flavored Markdown to clean HTML using CommonMark</li><li><strong>Flexible Mapping:</strong> Map individual MD files to specific WordPress posts or pages</li><li><strong>Title from H1:</strong> Post titles are derived from the first Markdown H1 heading</li><li><strong>Link & Image Translation:</strong> Repo-relative links are rewritten to permalinks; images are sideloaded into the Media Library</li><li><strong>Secure:</strong> Encrypted GitHub token storage (HKDF-SHA256), full input validation & output escaping</li><li><strong>Translatable:</strong> Full internationalization support (i18n/l10n ready)</li><li><strong>Multi-Repository:</strong> Sync from multiple GitHub repos simultaneously</li><li><strong>Manual Sync:</strong> On-demand synchronization from admin interface</li><li><strong>Debug Tools:</strong> Built-in debugging tools (visible when WP_DEBUG is enabled)</li></ul><h4>Use Cases</h4><ul><li>API Documentation - Keep your API docs in sync between GitHub and WordPress</li><li>Technical Documentation - Maintain version-controlled technical docs</li><li>Knowledge Base - Build a knowledge base powered by GitHub</li><li>Blog Posts - Write blog posts in Markdown with Git workflow</li><li>Product Documentation - Sync product documentation from your repository</li></ul><h4>Requirements</h4><ul><li>PHP 8.0 or higher</li><li>WordPress 6.8 or higher</li><li>GitHub Personal Access Token (free)</li></ul>",
|
||||||
"installation": "<h4>Installation</h4><ol><li>Upload the plugin files to <code>/wp-content/plugins/robotstxt-documentation-markdown/</code></li><li>Activate the plugin through the 'Plugins' menu in WordPress</li><li>Navigate to 'Documentation → Settings' in the WordPress admin menu</li><li>Generate a GitHub Personal Access Token:<ul><li>Go to GitHub → Settings → Developer settings → Personal access tokens</li><li>Click 'Generate new token'</li><li>For public repositories: No specific scopes needed</li><li>For private repositories: Select <code>repo</code> scope</li></ul></li><li>Paste your token in the plugin settings and save</li><li>Create your first mapping under 'Documentation → Mappings'</li></ol><h4>Configuration</h4><ol><li>Go to <strong>Documentation → Add Mapping</strong></li><li>Fill in the repository details (owner, name, file path, branch)</li><li>Configure target content (post type, author, parent, order)</li><li>Set synchronization frequency</li><li>Save and click 'Sync Now' to perform first sync</li></ol>",
|
"changelog": ""
|
||||||
"faq": "<h4>Do I need a GitHub account?</h4><p>Yes, you need a GitHub account to generate a Personal Access Token. The token is required to access repositories (public or private).</p><h4>Can I sync from private repositories?</h4><p>Yes! When generating your GitHub Personal Access Token, make sure to select the <code>repo</code> scope for full access to private repositories.</p><h4>How often does synchronization happen?</h4><p>You can configure synchronization frequency per mapping: Manual only, Hourly, Twice daily, or Daily. You can also manually trigger sync at any time.</p><h4>Will the plugin delete my WordPress content if I uninstall it?</h4><p>By default, NO. When you uninstall the plugin, it preserves all synced WordPress pages/posts. However, there's an option in Settings to delete plugin data on uninstall - but this never deletes the actual WordPress content.</p><h4>Can I sync multiple files from the same repository?</h4><p>Yes! You can create multiple mappings, each pointing to different files in the same repository or different repositories.</p><h4>How do I debug synchronization issues?</h4><p>Enable WP_DEBUG in your wp-config.php, then go to Documentation → Settings. You'll see a 'Debug Tools' section with options to test GitHub connection, validate token, view cron jobs, and run manual syncs.</p>",
|
|
||||||
"changelog": "<h3>1.2.0 - 2026-08-07</h3><h4>Added</h4><ul><li><strong>Title from H1:</strong> synced post title taken from the first Markdown H1 (and stripped from the body)</li><li><strong>Internal link translation:</strong> repo-relative links rewritten to the matching WordPress permalink</li><li><strong>Image sideloading:</strong> repository images imported into the Media Library and referenced by attachment URL</li></ul><h4>Security</h4><ul><li>Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)</li><li>GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)</li><li>CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d</li></ul><h3>1.1.1 - 2026-06-08</h3><h4>Security</h4><ul><li>CommonMark raw HTML passthrough disabled; token option no longer autoloaded</li></ul><h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li>Core synchronization, CommonMark conversion, encrypted token storage, mapping system, multi-repo support, debug tools</li></ul>"
|
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"low": "",
|
"low": "",
|
||||||
|
|
|
||||||
3
vendor/composer/autoload_classmap.php
vendored
3
vendor/composer/autoload_classmap.php
vendored
|
|
@ -200,6 +200,7 @@ return array(
|
||||||
'League\\CommonMark\\Extension\\Strikethrough\\StrikethroughRenderer' => $vendorDir . '/league/commonmark/src/Extension/Strikethrough/StrikethroughRenderer.php',
|
'League\\CommonMark\\Extension\\Strikethrough\\StrikethroughRenderer' => $vendorDir . '/league/commonmark/src/Extension/Strikethrough/StrikethroughRenderer.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContents' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContents.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContents' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContents.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsPlaceholder' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsPlaceholder.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsPlaceholder' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsPlaceholder.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsReference' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\AsIsNormalizerStrategy' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/AsIsNormalizerStrategy.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\AsIsNormalizerStrategy' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/AsIsNormalizerStrategy.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\FlatNormalizerStrategy' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/FlatNormalizerStrategy.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\FlatNormalizerStrategy' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/FlatNormalizerStrategy.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\NormalizerStrategyInterface' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/NormalizerStrategyInterface.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\NormalizerStrategyInterface' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/Normalizer/NormalizerStrategyInterface.php',
|
||||||
|
|
@ -210,6 +211,8 @@ return array(
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsGeneratorInterface' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsGeneratorInterface' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderParser' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderParser' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderRenderer' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderRenderer.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderRenderer' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderRenderer.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsReferenceRenderer' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderCache' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderer' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderer.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderer' => $vendorDir . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderer.php',
|
||||||
'League\\CommonMark\\Extension\\Table\\Table' => $vendorDir . '/league/commonmark/src/Extension/Table/Table.php',
|
'League\\CommonMark\\Extension\\Table\\Table' => $vendorDir . '/league/commonmark/src/Extension/Table/Table.php',
|
||||||
'League\\CommonMark\\Extension\\Table\\TableCell' => $vendorDir . '/league/commonmark/src/Extension/Table/TableCell.php',
|
'League\\CommonMark\\Extension\\Table\\TableCell' => $vendorDir . '/league/commonmark/src/Extension/Table/TableCell.php',
|
||||||
|
|
|
||||||
3
vendor/composer/autoload_static.php
vendored
3
vendor/composer/autoload_static.php
vendored
|
|
@ -258,6 +258,7 @@ class ComposerStaticInitc097ca862608a882f291ae6b935e271c
|
||||||
'League\\CommonMark\\Extension\\Strikethrough\\StrikethroughRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Strikethrough/StrikethroughRenderer.php',
|
'League\\CommonMark\\Extension\\Strikethrough\\StrikethroughRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Strikethrough/StrikethroughRenderer.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContents' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContents.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContents' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContents.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsPlaceholder' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsPlaceholder.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsPlaceholder' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsPlaceholder.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\Node\\TableOfContentsReference' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\AsIsNormalizerStrategy' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/AsIsNormalizerStrategy.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\AsIsNormalizerStrategy' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/AsIsNormalizerStrategy.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\FlatNormalizerStrategy' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/FlatNormalizerStrategy.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\FlatNormalizerStrategy' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/FlatNormalizerStrategy.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\NormalizerStrategyInterface' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/NormalizerStrategyInterface.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\Normalizer\\NormalizerStrategyInterface' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/Normalizer/NormalizerStrategyInterface.php',
|
||||||
|
|
@ -268,6 +269,8 @@ class ComposerStaticInitc097ca862608a882f291ae6b935e271c
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsGeneratorInterface' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsGeneratorInterface' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderParser' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderParser' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderRenderer.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsPlaceholderRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderRenderer.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsReferenceRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php',
|
||||||
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderCache' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php',
|
||||||
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderer.php',
|
'League\\CommonMark\\Extension\\TableOfContents\\TableOfContentsRenderer' => __DIR__ . '/..' . '/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderer.php',
|
||||||
'League\\CommonMark\\Extension\\Table\\Table' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/Table.php',
|
'League\\CommonMark\\Extension\\Table\\Table' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/Table.php',
|
||||||
'League\\CommonMark\\Extension\\Table\\TableCell' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/TableCell.php',
|
'League\\CommonMark\\Extension\\Table\\TableCell' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/TableCell.php',
|
||||||
|
|
|
||||||
14
vendor/composer/installed.json
vendored
14
vendor/composer/installed.json
vendored
|
|
@ -80,17 +80,17 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.9.0",
|
"version": "2.10.0",
|
||||||
"version_normalized": "2.9.0.0",
|
"version_normalized": "2.10.0.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -124,11 +124,11 @@
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
|
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
|
||||||
},
|
},
|
||||||
"time": "2026-08-03T13:42:31+00:00",
|
"time": "2026-08-11T16:06:25+00:00",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.10-dev"
|
"dev-main": "2.11-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"installation-source": "dist",
|
"installation-source": "dist",
|
||||||
|
|
|
||||||
6
vendor/composer/installed.php
vendored
6
vendor/composer/installed.php
vendored
|
|
@ -20,9 +20,9 @@
|
||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'league/commonmark' => array(
|
'league/commonmark' => array(
|
||||||
'pretty_version' => '2.9.0',
|
'pretty_version' => '2.10.0',
|
||||||
'version' => '2.9.0.0',
|
'version' => '2.10.0.0',
|
||||||
'reference' => '5703d83ba3da3b2e356a5fedc848ed6d8ffb6529',
|
'reference' => 'd2d1aa8b35e072966c89bc0c66cf926e56767dc4',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/../league/commonmark',
|
'install_path' => __DIR__ . '/../league/commonmark',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
|
|
|
||||||
68
vendor/league/commonmark/CHANGELOG.md
vendored
68
vendor/league/commonmark/CHANGELOG.md
vendored
|
|
@ -6,6 +6,69 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
|
||||||
|
|
||||||
## [Unreleased][unreleased]
|
## [Unreleased][unreleased]
|
||||||
|
|
||||||
|
## [2.10.0] - 2026-08-11
|
||||||
|
|
||||||
|
This is a **security release** to address a denial of service vulnerability in the `AttributesExtension`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added a new `table_of_contents/max_placeholder_entries` option to limit how many table of contents entries a document may render across all of its placeholders (#1134)
|
||||||
|
- Added `Cursor::matchInPlace()`, which matches a regular expression at the cursor's position within the line using PCRE's native offset semantics instead of copying the remainder (#1145)
|
||||||
|
- `\G` anchors at the cursor, `^` anchors at the start of the line, and lookbehinds and `\b` see the characters actually preceding the cursor; this keeps scanning loops linear and enables left-context assertions that `match()` cannot express
|
||||||
|
- Added `RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED` and `RegexHelper::PARTIAL_LINK_DESTINATION_BRACES`, unanchored fragments so each call site can supply its own anchor
|
||||||
|
- Added a `default_attributes` configuration format which pairs the node attribute map with a new `strict_callables` option: `['default_attributes' => ['attributes' => [...], 'strict_callables' => true]]`. With `strict_callables` enabled, only closures and invokable objects are treated as callbacks, so strings and arrays are always used as literal attribute values. Callbacks written as string or array callables can be wrapped with `Closure::fromCallable()`. The original format - passing the node map directly - is still accepted, and defaults `strict_callables` to `false`.
|
||||||
|
- Added a new `slug_normalizer/reserved` option which treats the given slugs as already-used, so colliding headings receive an incremental numeric suffix just like duplicate headings do (#1080)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Changed the `TableOfContents` extension to render the table of contents once and share it across all placeholders instead of cloning it into each one (#1134)
|
||||||
|
- A custom renderer registered for the `TableOfContents` node is no longer called once per placeholder, so it must return the same markup each time it is called for a given document (#1134)
|
||||||
|
- The first placeholder receives the table of contents itself, so a document still contains a `TableOfContents` node for listeners which locate and reposition it (#1143)
|
||||||
|
- `$environment->getConfiguration()->get('default_attributes')` now returns the normalized structure with `attributes` and `strict_callables` keys instead of the node map; read `default_attributes/attributes` to get the map. Configuration written in either format continues to work unchanged.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- Deprecated `RegexHelper::PARTIAL_LINK_TITLE` and `RegexHelper::REGEX_LINK_DESTINATION_BRACES`; use the unanchored variants with an explicit anchor instead
|
||||||
|
- Deprecated the `default_attributes` `strict_callables` option, which will be removed in 3.0 when only closures and invokable objects will ever be treated as callbacks.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed `default_attributes` values which happen to match the name of a PHP function - such as `'class' => 'link'`, `'header'`, `'key'`, `'range'`, or `'current'` - being invoked as callbacks, producing errors like `link() expects exactly 2 arguments, 1 given`. Enable `strict_callables` to treat strings and arrays as literal attribute values (#1123)
|
||||||
|
- Fixed the `DefaultAttributesExtension` re-testing every configured value with `is_callable()` once per matching node, which asked the autoloader whether the first element of each array value named a real class every single time
|
||||||
|
- Fixed a `default_attributes` value which PHP treats as callable reporting its failure from inside whichever function it collided with; the error now names the attribute and node class responsible, and keeps the original error as its previous exception
|
||||||
|
- Fixed custom `UniqueSlugNormalizerInterface` implementations being wrapped by the built-in `UniqueSlugNormalizer` and never receiving the documented `clearHistory()` calls, which caused slug history to leak across documents when `slug_normalizer/unique` was set to `'document'` (#1080)
|
||||||
|
- Custom implementations are now trusted to enforce uniqueness themselves, per the interface contract; the extra deduplication layer the wrapper used to provide is no longer applied on top of them
|
||||||
|
- Fixed the `AttributesExtension` re-merging and re-filtering everything a node had already collected each time another attribute node was applied to it, causing long runs of distinctly-named attributes to be resolved in quadratic time, which could be abused to cause a denial of service - this completes the fix for GHSA-jjv6-8j6v-6j52, which covered only the `class` attribute (GHSA-8rr7-cvq3-gmfh)
|
||||||
|
- Fixed the `AttributesExtension` re-merging everything an attribute block had already collected on each of its continuation lines, causing long runs of distinctly-named attributes on consecutive lines to be resolved in quadratic time, which could be abused to cause a denial of service (GHSA-8rr7-cvq3-gmfh)
|
||||||
|
|
||||||
|
## [2.9.2] - 2026-08-10
|
||||||
|
|
||||||
|
This release fixes a regression introduced in 2.9.0 which changed the behavior of `Cursor::match()` for certain regular expression patterns.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved performance of reading single characters from multibyte lines
|
||||||
|
- Improved performance of locating the next non-space character on lines without tabs
|
||||||
|
- Optimized `Cursor::advanceToNextNonSpaceOrNewline()` to scan the line in place instead of copying everything left in the block on every call
|
||||||
|
- Optimized inline link destination parsing to scan the line in place, so its cost follows the length of the destination rather than the length of everything left in the block
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed a regression introduced in 2.9.0 where `Cursor::match()` treated text before the cursor as part of the match subject (#1145). Patterns were matched against the whole line at an offset, which silently changed the meaning of `\b`, `\B`, `\A`, lookbehinds, a `^` anywhere other than the very start of the pattern, and a leading `^` combined with the `m` modifier. `match()` once again matches against the remainder, exactly as it did in 2.8; the core parsers keep the optimized in-place matching via a new internal method with PCRE's native offset semantics, anchoring their patterns at the cursor with `\G`
|
||||||
|
- Fixed heading permalinks rendered with `aria-hidden="true"` remaining in the keyboard tab order; they are now also given `tabindex="-1"`, as a focusable element removed from the accessibility tree has no accessible name to announce when focused (WCAG 4.1.2)
|
||||||
|
- Fixed cloning a node breaking the link from the original node's children back to their parent, silently corrupting the document that node belonged to; detaching or inserting around those children afterwards could drop nodes from the tree
|
||||||
|
- Fixed cloned nodes sharing their `data` with the node they were cloned from, so that setting an attribute on either one also set it on the other
|
||||||
|
|
||||||
|
## [2.9.1] - 2026-08-09
|
||||||
|
|
||||||
|
This is a **security release** to address multiple denial of service vulnerabilities and one cross-site scripting (XSS) vulnerability.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Shortcut and collapsed reference links (`[label]` and `[label][]`) now apply the spec's 999-character link label limit when resolving the label, matching the limit already enforced when parsing reference definitions and when resolving the `[text][label]` form. A label longer than 999 characters which collapsed to a shorter, defined label once whitespace was normalized will no longer resolve; this matches cmark's behavior.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed attribute names prefixed with a form feed (such as `{<FF>onclick="..."}`) bypassing both the `on*` event handler filter and the `allow_unsafe_links` protection, as browsers treat that byte as whitespace and parse the name as a genuine `onclick` or `href` (GHSA-f8fg-pg57-v4j8)
|
||||||
|
- Fixed catastrophic backtracking in the fenced code block start pattern, causing a single line of backticks to be scanned in quadratic time, which could be abused to cause a denial of service (GHSA-j8pm-gj4c-rq4x)
|
||||||
|
- Fixed shortcut reference link lookups normalizing arbitrarily long labels once a single reference definition is present, causing nested brackets to be resolved in quadratic time, which could be abused to cause a denial of service (GHSA-j8pm-gj4c-rq4x)
|
||||||
|
- Fixed delimiter processors keying the opener-search cache on the raw closer run length, leaving the cache key space unbounded and causing emphasis, strikethrough, and highlight runs to be processed in super-linear time, which could be abused to cause a denial of service (GHSA-j8pm-gj4c-rq4x)
|
||||||
|
- Fixed the `SmartPunctExtension` recopying the whole preceding text node when replacing each unpaired quote, causing documents with many apostrophes to be processed in quadratic time, which could be abused to cause a denial of service (GHSA-jjv6-8j6v-6j52)
|
||||||
|
- Fixed the `AttributesExtension` scanning the remaining siblings of every block-level attribute node, causing long runs of adjacent attribute blocks to be resolved in quadratic time, which could be abused to cause a denial of service - this completes the fix for GHSA-g2gp-3wwq-f4ph, which covered only inline attributes (GHSA-jjv6-8j6v-6j52)
|
||||||
|
- Fixed the `AttributesExtension` rebuilding the accumulated class list on every merge, causing long runs of `.class` attributes to be resolved in quadratic time, which could be abused to cause a denial of service (GHSA-jjv6-8j6v-6j52)
|
||||||
|
|
||||||
## [2.9.0] - 2026-08-03
|
## [2.9.0] - 2026-08-03
|
||||||
|
|
||||||
This is a **security release** to address five denial of service vulnerabilities and one cross-site scripting (XSS) vulnerability.
|
This is a **security release** to address five denial of service vulnerabilities and one cross-site scripting (XSS) vulnerability.
|
||||||
|
|
@ -771,7 +834,10 @@ No changes were introduced since the previous release.
|
||||||
- Alternative 1: Use `CommonMarkConverter` or `GithubFlavoredMarkdownConverter` if you don't need to customize the environment
|
- Alternative 1: Use `CommonMarkConverter` or `GithubFlavoredMarkdownConverter` if you don't need to customize the environment
|
||||||
- Alternative 2: Instantiate a new `Environment` and add the necessary extensions yourself
|
- Alternative 2: Instantiate a new `Environment` and add the necessary extensions yourself
|
||||||
|
|
||||||
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.9.0...HEAD
|
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.10.0...HEAD
|
||||||
|
[2.10.0]: https://github.com/thephpleague/commonmark/compare/2.9.2...2.10.0
|
||||||
|
[2.9.2]: https://github.com/thephpleague/commonmark/compare/2.9.1...2.9.2
|
||||||
|
[2.9.1]: https://github.com/thephpleague/commonmark/compare/2.9.0...2.9.1
|
||||||
[2.9.0]: https://github.com/thephpleague/commonmark/compare/2.8.3...2.9.0
|
[2.9.0]: https://github.com/thephpleague/commonmark/compare/2.8.3...2.9.0
|
||||||
[2.8.3]: https://github.com/thephpleague/commonmark/compare/2.8.2...2.8.3
|
[2.8.3]: https://github.com/thephpleague/commonmark/compare/2.8.2...2.8.3
|
||||||
[2.8.2]: https://github.com/thephpleague/commonmark/compare/2.8.1...2.8.2
|
[2.8.2]: https://github.com/thephpleague/commonmark/compare/2.8.1...2.8.2
|
||||||
|
|
|
||||||
2
vendor/league/commonmark/composer.json
vendored
2
vendor/league/commonmark/composer.json
vendored
|
|
@ -116,7 +116,7 @@
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.10-dev"
|
"dev-main": "2.11-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,13 @@ final class Environment implements EnvironmentInterface, EnvironmentBuilderInter
|
||||||
\assert($normalizer instanceof TextNormalizerInterface);
|
\assert($normalizer instanceof TextNormalizerInterface);
|
||||||
$this->injectEnvironmentAndConfigurationIfNeeded($normalizer);
|
$this->injectEnvironmentAndConfigurationIfNeeded($normalizer);
|
||||||
|
|
||||||
if ($this->config->get('slug_normalizer/unique') !== UniqueSlugNormalizerInterface::DISABLED && ! $normalizer instanceof UniqueSlugNormalizer) {
|
if ($this->config->get('slug_normalizer/unique') !== UniqueSlugNormalizerInterface::DISABLED && ! $normalizer instanceof UniqueSlugNormalizerInterface) {
|
||||||
$normalizer = new UniqueSlugNormalizer($normalizer);
|
/** @var string[] $reserved */
|
||||||
|
$reserved = $this->config->get('slug_normalizer/reserved');
|
||||||
|
$normalizer = new UniqueSlugNormalizer($normalizer, $reserved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizer instanceof UniqueSlugNormalizer) {
|
if ($normalizer instanceof UniqueSlugNormalizerInterface) {
|
||||||
if ($this->config->get('slug_normalizer/unique') === UniqueSlugNormalizerInterface::PER_DOCUMENT) {
|
if ($this->config->get('slug_normalizer/unique') === UniqueSlugNormalizerInterface::PER_DOCUMENT) {
|
||||||
$this->addEventListener(DocumentParsedEvent::class, [$normalizer, 'clearHistory'], -1000);
|
$this->addEventListener(DocumentParsedEvent::class, [$normalizer, 'clearHistory'], -1000);
|
||||||
}
|
}
|
||||||
|
|
@ -445,6 +447,7 @@ final class Environment implements EnvironmentInterface, EnvironmentBuilderInter
|
||||||
'instance' => Expect::type(TextNormalizerInterface::class)->default(new SlugNormalizer()),
|
'instance' => Expect::type(TextNormalizerInterface::class)->default(new SlugNormalizer()),
|
||||||
'max_length' => Expect::int()->min(0)->default(255),
|
'max_length' => Expect::int()->min(0)->default(255),
|
||||||
'unique' => Expect::anyOf(UniqueSlugNormalizerInterface::DISABLED, UniqueSlugNormalizerInterface::PER_ENVIRONMENT, UniqueSlugNormalizerInterface::PER_DOCUMENT)->default(UniqueSlugNormalizerInterface::PER_DOCUMENT),
|
'unique' => Expect::anyOf(UniqueSlugNormalizerInterface::DISABLED, UniqueSlugNormalizerInterface::PER_ENVIRONMENT, UniqueSlugNormalizerInterface::PER_DOCUMENT)->default(UniqueSlugNormalizerInterface::PER_DOCUMENT),
|
||||||
|
'reserved' => Expect::listOf('string')->default([]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||||
use League\CommonMark\Node\Inline\AbstractInline;
|
use League\CommonMark\Node\Inline\AbstractInline;
|
||||||
use League\CommonMark\Node\Node;
|
use League\CommonMark\Node\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @psalm-type PendingAttributes = array{node: Node, front: array<string, mixed>, back: array<string, mixed>, classFront: list<string>, classBack: list<string>, hasClass: bool, unfiltered: array<string, mixed>}
|
||||||
|
*
|
||||||
|
* @phpstan-type PendingAttributes = array{node: Node, front: array<string, mixed>, back: array<string, mixed>, classFront: list<string>, classBack: list<string>, hasClass: bool, unfiltered: array<string, mixed>}
|
||||||
|
*/
|
||||||
final class AttributesListener
|
final class AttributesListener
|
||||||
{
|
{
|
||||||
private const DIRECTION_PREFIX = 'prefix';
|
private const DIRECTION_PREFIX = 'prefix';
|
||||||
|
|
@ -44,12 +49,24 @@ final class AttributesListener
|
||||||
|
|
||||||
public function processDocument(DocumentParsedEvent $event): void
|
public function processDocument(DocumentParsedEvent $event): void
|
||||||
{
|
{
|
||||||
|
// Targets already worked out for attribute blocks we walked past; see findTargetAndDirection()
|
||||||
|
/** @var \SplObjectStorage<Attributes|AttributesInline, array<Node|string|null>> $resolved */
|
||||||
|
$resolved = new \SplObjectStorage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attributes waiting to be written to their target, keyed by target object ID; see
|
||||||
|
* newPendingEntry() for the shape and contribute() for how each node is folded in.
|
||||||
|
*
|
||||||
|
* @var array<int, PendingAttributes> $pending
|
||||||
|
*/
|
||||||
|
$pending = [];
|
||||||
|
|
||||||
foreach ($event->getDocument()->iterator() as $node) {
|
foreach ($event->getDocument()->iterator() as $node) {
|
||||||
if (! ($node instanceof Attributes || $node instanceof AttributesInline)) {
|
if (! ($node instanceof Attributes || $node instanceof AttributesInline)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$target, $direction] = self::findTargetAndDirection($node);
|
[$target, $direction] = self::findTargetAndDirection($node, $resolved);
|
||||||
|
|
||||||
if ($target instanceof Node) {
|
if ($target instanceof Node) {
|
||||||
$parent = $target->parent();
|
$parent = $target->parent();
|
||||||
|
|
@ -57,29 +74,239 @@ final class AttributesListener
|
||||||
$target = $parent;
|
$target = $parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($direction === self::DIRECTION_SUFFIX) {
|
$id = \spl_object_id($target);
|
||||||
$attributes = AttributesHelper::mergeAttributes($target, $node->getAttributes());
|
if (! isset($pending[$id])) {
|
||||||
} else {
|
$pending[$id] = self::newPendingEntry($target);
|
||||||
$attributes = AttributesHelper::mergeAttributes($node->getAttributes(), $target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$target->data->set('attributes', AttributesHelper::filterAttributes($attributes, $this->allowList, $this->allowUnsafeLinks));
|
$this->contribute($pending[$id], $node->getAttributes(), $direction === self::DIRECTION_SUFFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
$node->detach();
|
$node->detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($pending as $entry) {
|
||||||
|
$entry['node']->data->set('attributes', self::assemble($entry));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Attributes|AttributesInline $node
|
* Seed an accumulator from whatever the target already carries
|
||||||
|
*
|
||||||
|
* Anything another extension put there takes part in the merges, so it faces the filter too -
|
||||||
|
* but only once the first node has had its say, since whatever that node overwrites never
|
||||||
|
* reaches the filter at all.
|
||||||
|
*
|
||||||
|
* @return PendingAttributes
|
||||||
|
*/
|
||||||
|
private static function newPendingEntry(Node $target): array
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $existing */
|
||||||
|
$existing = (array) $target->data->get('attributes');
|
||||||
|
|
||||||
|
$entry = ['node' => $target, 'front' => [], 'back' => [], 'classFront' => [], 'classBack' => [], 'hasClass' => false, 'unfiltered' => $existing];
|
||||||
|
|
||||||
|
foreach ($existing as $name => $value) {
|
||||||
|
if ($name === 'class') {
|
||||||
|
// mergeAttributes() builds the class list by appending to it, so a value with no
|
||||||
|
// classes in it - an empty string, or an empty array - never creates the key at
|
||||||
|
// all, and leaves the target with no class rather than an empty one
|
||||||
|
$classes = AttributesHelper::classList($value);
|
||||||
|
if ($classes === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['classBack'] = $classes;
|
||||||
|
$entry['hasClass'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['back'][$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one node's attributes into the accumulator in time proportional to that node's own
|
||||||
|
* size, reproducing what mergeAttributes() plus filterAttributes() would have produced had
|
||||||
|
* they been handed the whole accumulated dict.
|
||||||
|
*
|
||||||
|
* Both of those are O(size of the accumulator), so calling them once per node - as this used
|
||||||
|
* to - costs O(n^2) for a document that contributes a new key n times.
|
||||||
|
*
|
||||||
|
* @param PendingAttributes $entry
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @param bool $suffix Whether the node's attributes lose conflicts to what is already accumulated
|
||||||
|
*/
|
||||||
|
private function contribute(array &$entry, array $attributes, bool $suffix): void
|
||||||
|
{
|
||||||
|
$class = $attributes['class'] ?? null;
|
||||||
|
unset($attributes['class']);
|
||||||
|
|
||||||
|
// Keys whose value this node decides, and which therefore have yet to face the filter
|
||||||
|
$touched = [];
|
||||||
|
|
||||||
|
if ($suffix) {
|
||||||
|
// mergeAttributes() rebuilds the class list before anything else, so merging over an
|
||||||
|
// accumulator that already has one moves it back to the front
|
||||||
|
if ($entry['hasClass']) {
|
||||||
|
self::moveToFront($entry, 'class');
|
||||||
|
} elseif ($class !== null) {
|
||||||
|
$entry['back']['class'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
foreach (AttributesHelper::classList($class) as $c) {
|
||||||
|
$entry['classBack'][] = $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['hasClass'] = true;
|
||||||
|
$touched['class'] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($attributes as $name => $value) {
|
||||||
|
// Overwriting leaves a key where it already sits, in front or behind
|
||||||
|
if (\array_key_exists($name, $entry['front'])) {
|
||||||
|
$entry['front'][$name] = $value;
|
||||||
|
} else {
|
||||||
|
$entry['back'][$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touched[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->filter($entry, $touched);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This node goes in front of everything accumulated so far. 'front' is kept in reverse, so
|
||||||
|
// the keys most recently moved there are the ones assemble() emits first.
|
||||||
|
if ($class === null && $entry['hasClass']) {
|
||||||
|
self::moveToFront($entry, 'class');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (\array_reverse(\array_keys($attributes)) as $name) {
|
||||||
|
// A key this node shares with the accumulator takes its position from here, but keeps
|
||||||
|
// the value it already had
|
||||||
|
if (\array_key_exists($name, $entry['front']) || \array_key_exists($name, $entry['back'])) {
|
||||||
|
self::moveToFront($entry, $name);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['front'][$name] = $attributes[$name];
|
||||||
|
$touched[$name] = $attributes[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
self::moveToFront($entry, 'class');
|
||||||
|
foreach (\array_reverse(AttributesHelper::classList($class)) as $c) {
|
||||||
|
$entry['classFront'][] = $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['hasClass'] = true;
|
||||||
|
$touched['class'] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->filter($entry, $touched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a key the frontmost position, keeping whatever value it already had
|
||||||
|
*
|
||||||
|
* @param PendingAttributes $entry
|
||||||
|
*/
|
||||||
|
private static function moveToFront(array &$entry, string $name): void
|
||||||
|
{
|
||||||
|
$value = $entry['front'][$name] ?? $entry['back'][$name] ?? null;
|
||||||
|
unset($entry['front'][$name], $entry['back'][$name]);
|
||||||
|
|
||||||
|
$entry['front'][$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop whichever of the just-decided keys the filter rejects
|
||||||
|
*
|
||||||
|
* Everything else in the accumulator survived this same filter when it was decided, and the
|
||||||
|
* filter judges each name and value on its own, so re-examining any of it would be wasted.
|
||||||
|
*
|
||||||
|
* @param PendingAttributes $entry
|
||||||
|
* @param array<string, mixed> $touched
|
||||||
|
*/
|
||||||
|
private function filter(array &$entry, array $touched): void
|
||||||
|
{
|
||||||
|
// Whatever the target arrived with has kept its value through this first merge, so it is
|
||||||
|
// being decided now too
|
||||||
|
foreach ($entry['unfiltered'] as $name => $value) {
|
||||||
|
if (\array_key_exists($name, $touched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touched[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['unfiltered'] = [];
|
||||||
|
|
||||||
|
if ($touched === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kept = AttributesHelper::filterAttributes($touched, $this->allowList, $this->allowUnsafeLinks);
|
||||||
|
|
||||||
|
foreach (\array_keys($touched) as $name) {
|
||||||
|
if (\array_key_exists($name, $kept)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'class') {
|
||||||
|
$entry['classFront'] = $entry['classBack'] = [];
|
||||||
|
$entry['hasClass'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget where it sat as well as what it held, so a later node contributing the same
|
||||||
|
// name starts it over at the back
|
||||||
|
unset($entry['front'][$name], $entry['back'][$name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PendingAttributes $entry
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function assemble(array $entry): array
|
||||||
|
{
|
||||||
|
$attributes = \array_merge(\array_reverse($entry['front'], true), $entry['back']);
|
||||||
|
|
||||||
|
if ($entry['hasClass']) {
|
||||||
|
$attributes['class'] = \implode(' ', \array_merge(\array_reverse($entry['classFront']), $entry['classBack']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Attributes|AttributesInline $node
|
||||||
|
* @param \SplObjectStorage<Attributes|AttributesInline, array<Node|string|null>> $resolved
|
||||||
*
|
*
|
||||||
* @return array<Node|string|null>
|
* @return array<Node|string|null>
|
||||||
*/
|
*/
|
||||||
private static function findTargetAndDirection($node): array
|
private static function findTargetAndDirection($node, \SplObjectStorage $resolved): array
|
||||||
{
|
{
|
||||||
|
if (isset($resolved[$node])) {
|
||||||
|
$result = $resolved[$node];
|
||||||
|
// Each node is only ever resolved once, so don't keep it alive any longer
|
||||||
|
unset($resolved[$node]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
$target = null;
|
$target = null;
|
||||||
$direction = null;
|
$direction = null;
|
||||||
$previous = $next = $node;
|
$previous = $next = $node;
|
||||||
|
/** @var list<Attributes> $shared */
|
||||||
|
$shared = [];
|
||||||
while (true) {
|
while (true) {
|
||||||
$previous = self::getPrevious($previous);
|
$previous = self::getPrevious($previous);
|
||||||
$next = self::getNext($next);
|
$next = self::getNext($next);
|
||||||
|
|
@ -119,9 +346,27 @@ final class AttributesListener
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only a TARGET_NEXT block can have a sibling block to walk past, so if that's what
|
||||||
|
// we're looking at then we are one too, which means getPrevious() already returned
|
||||||
|
// null for us and will keep doing so. Neither of us can therefore pick anything up on
|
||||||
|
// the left, and what is left of this walk is the whole of that block's own walk: it
|
||||||
|
// must end up with the same target we do. Remember that instead of re-scanning the
|
||||||
|
// chain once per block.
|
||||||
|
if (! ($next instanceof Attributes) || $next->getTarget() !== Attributes::TARGET_NEXT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shared[] = $next;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$target, $direction];
|
/** @var array<Node|string|null> $result */
|
||||||
|
$result = [$target, $direction];
|
||||||
|
foreach ($shared as $sharedNode) {
|
||||||
|
$resolved[$sharedNode] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,27 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser
|
||||||
|
|
||||||
private bool $hasSubsequentLine = false;
|
private bool $hasSubsequentLine = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every attribute merged so far except the class list, which is accumulated separately and
|
||||||
|
* only takes the placeholder position recorded here. Re-merging the whole dict on every line
|
||||||
|
* would cost O(size of the dict) per line, so a document adding a key per line would be
|
||||||
|
* quadratic; folding each line in on its own is linear.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
private array $attributes = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $classes = [];
|
||||||
|
|
||||||
|
private bool $hasClass = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mergeAttributes() rebuilds the class list before anything else, so any line merged over an
|
||||||
|
* existing list moves it to the front of the result
|
||||||
|
*/
|
||||||
|
private bool $classFirst = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $attributes The attributes identified by the block start parser
|
* @param array<string, mixed> $attributes The attributes identified by the block start parser
|
||||||
* @param AbstractBlock $container The node we were in when these attributes were discovered
|
* @param AbstractBlock $container The node we were in when these attributes were discovered
|
||||||
|
|
@ -39,6 +60,8 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser
|
||||||
$this->block = new Attributes($attributes);
|
$this->block = new Attributes($attributes);
|
||||||
|
|
||||||
$this->container = $container;
|
$this->container = $container;
|
||||||
|
|
||||||
|
$this->absorb($attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBlock(): AbstractBlock
|
public function getBlock(): AbstractBlock
|
||||||
|
|
@ -57,10 +80,9 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser
|
||||||
$cursor->advanceToNextNonSpaceOrTab();
|
$cursor->advanceToNextNonSpaceOrTab();
|
||||||
if ($cursor->isAtEnd() && $attributes !== []) {
|
if ($cursor->isAtEnd() && $attributes !== []) {
|
||||||
// It does! Merge them into what we parsed previously
|
// It does! Merge them into what we parsed previously
|
||||||
$this->block->setAttributes(AttributesHelper::mergeAttributes(
|
$this->classFirst = $this->classFirst || $this->hasClass;
|
||||||
$this->block->getAttributes(),
|
|
||||||
$attributes
|
$this->absorb($attributes);
|
||||||
));
|
|
||||||
|
|
||||||
// Tell the core parser we've consumed everything
|
// Tell the core parser we've consumed everything
|
||||||
return BlockContinue::at($cursor);
|
return BlockContinue::at($cursor);
|
||||||
|
|
@ -75,8 +97,49 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser
|
||||||
return BlockContinue::none();
|
return BlockContinue::none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one line's attributes into what we've accumulated, in time proportional to that line
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
private function absorb(array $attributes): void
|
||||||
|
{
|
||||||
|
foreach ($attributes as $name => $value) {
|
||||||
|
if ($name !== 'class') {
|
||||||
|
$this->attributes[$name] = $value;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (AttributesHelper::classList($value) as $class) {
|
||||||
|
$this->classes[] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasClass) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold the spot the joined list will occupy; closeBlock() writes the value
|
||||||
|
$this->attributes['class'] = null;
|
||||||
|
$this->hasClass = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function closeBlock(): void
|
public function closeBlock(): void
|
||||||
{
|
{
|
||||||
|
$attributes = $this->attributes;
|
||||||
|
if ($this->hasClass) {
|
||||||
|
$class = \implode(' ', $this->classes);
|
||||||
|
if ($this->classFirst) {
|
||||||
|
unset($attributes['class']);
|
||||||
|
$attributes = \array_merge(['class' => $class], $attributes);
|
||||||
|
} else {
|
||||||
|
$attributes['class'] = $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->block->setAttributes($attributes);
|
||||||
|
|
||||||
// Attributes appearing at the very end of the document won't have any last lines to check
|
// Attributes appearing at the very end of the document won't have any last lines to check
|
||||||
// so we can make that determination here
|
// so we can make that determination here
|
||||||
if (! $this->hasSubsequentLine) {
|
if (! $this->hasSubsequentLine) {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,14 @@ use League\CommonMark\Util\RegexHelper;
|
||||||
final class AttributesHelper
|
final class AttributesHelper
|
||||||
{
|
{
|
||||||
private const SINGLE_ATTRIBUTE = '\s*([.]-?[_a-z][^\s.}]*|[#][^\s}]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
|
private const SINGLE_ATTRIBUTE = '\s*([.]-?[_a-z][^\s.}]*|[#][^\s}]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
|
||||||
private const ATTRIBUTE_LIST = '/^{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
|
private const ATTRIBUTE_LIST = '/\G{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCRE's `\s` matches the form feed that PHP's default trim charlist omits, so the
|
||||||
|
* separators SINGLE_ATTRIBUTE accepts must be trimmed with this list instead - otherwise
|
||||||
|
* that byte survives inside an attribute name, where a browser reads it as a separator.
|
||||||
|
*/
|
||||||
|
private const WHITESPACE = " \t\n\r\0\x0B\x0C";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|
@ -46,7 +53,7 @@ final class AttributesHelper
|
||||||
// matching individual attributes since they won't need to look ahead for the closing '}'
|
// matching individual attributes since they won't need to look ahead for the closing '}'
|
||||||
// while dealing with the fact that attributes can technically contain curly braces.
|
// while dealing with the fact that attributes can technically contain curly braces.
|
||||||
// So we'll just match the start and end braces up front.
|
// So we'll just match the start and end braces up front.
|
||||||
$attributeExpression = $cursor->match(self::ATTRIBUTE_LIST);
|
$attributeExpression = $cursor->matchInPlace(self::ATTRIBUTE_LIST);
|
||||||
if ($attributeExpression === null) {
|
if ($attributeExpression === null) {
|
||||||
$cursor->restoreState($state);
|
$cursor->restoreState($state);
|
||||||
|
|
||||||
|
|
@ -59,7 +66,7 @@ final class AttributesHelper
|
||||||
|
|
||||||
/** @var array<string, mixed> $attributes */
|
/** @var array<string, mixed> $attributes */
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
while ($attribute = \trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'))) {
|
while ($attribute = \trim((string) $attributeCursor->matchInPlace('/\G' . self::SINGLE_ATTRIBUTE . '/i'), self::WHITESPACE)) {
|
||||||
if ($attribute[0] === '#') {
|
if ($attribute[0] === '#') {
|
||||||
$attributes['id'] = \substr($attribute, 1);
|
$attributes['id'] = \substr($attribute, 1);
|
||||||
|
|
||||||
|
|
@ -86,12 +93,12 @@ final class AttributesHelper
|
||||||
$value = \substr($value, 1, -1);
|
$value = \substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (\strtolower(\trim($name)) === 'class') {
|
if (\strtolower(\trim($name, self::WHITESPACE)) === 'class') {
|
||||||
foreach (\array_filter(\explode(' ', \trim($value))) as $class) {
|
foreach (\array_filter(\explode(' ', \trim($value, self::WHITESPACE))) as $class) {
|
||||||
$attributes['class'][] = $class;
|
$attributes['class'][] = $class;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$attributes[\trim($name)] = \trim($value);
|
$attributes[\trim($name, self::WHITESPACE)] = \trim($value, self::WHITESPACE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,11 +126,7 @@ final class AttributesHelper
|
||||||
/** @var array<string, mixed> $arg */
|
/** @var array<string, mixed> $arg */
|
||||||
$arg = (array) $arg;
|
$arg = (array) $arg;
|
||||||
if (isset($arg['class'])) {
|
if (isset($arg['class'])) {
|
||||||
if (\is_string($arg['class'])) {
|
foreach (self::classList($arg['class']) as $class) {
|
||||||
$arg['class'] = \array_filter(\explode(' ', \trim($arg['class'])));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($arg['class'] as $class) {
|
|
||||||
$attributes['class'][] = $class;
|
$attributes['class'][] = $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +143,22 @@ final class AttributesHelper
|
||||||
return $attributes;
|
return $attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a `class` attribute value into the individual classes it contributes to a merge
|
||||||
|
*
|
||||||
|
* @param mixed $class
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function classList($class): array
|
||||||
|
{
|
||||||
|
if (\is_string($class)) {
|
||||||
|
return \array_values(\array_filter(\explode(' ', \trim($class))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return \array_values((array) $class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $attributes
|
* @param array<string, mixed> $attributes
|
||||||
* @param list<string> $allowList
|
* @param list<string> $allowList
|
||||||
|
|
@ -151,7 +170,15 @@ final class AttributesHelper
|
||||||
$allowList = \array_fill_keys($allowList, true);
|
$allowList = \array_fill_keys($allowList, true);
|
||||||
|
|
||||||
foreach ($attributes as $name => $value) {
|
foreach ($attributes as $name => $value) {
|
||||||
$attrNameLower = \strtolower($name);
|
// The checks below compare against literal names, and the renderer emits names
|
||||||
|
// without escaping them, so anything that isn't a well-formed attribute name
|
||||||
|
// would slip past both
|
||||||
|
if (\preg_match('/^' . RegexHelper::PARTIAL_ATTRIBUTENAME . '$/i', (string) $name) !== 1) {
|
||||||
|
unset($attributes[$name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrNameLower = \strtolower((string) $name);
|
||||||
|
|
||||||
// Remove any unsafe links
|
// Remove any unsafe links
|
||||||
if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) {
|
if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,10 @@ final class EmphasisDelimiterProcessor implements CacheableDelimiterProcessorInt
|
||||||
$this->char,
|
$this->char,
|
||||||
$closer->canOpen() ? 'canOpen' : 'cannotOpen',
|
$closer->canOpen() ? 'canOpen' : 'cannotOpen',
|
||||||
$closer->getOriginalLength() % 3,
|
$closer->getOriginalLength() % 3,
|
||||||
$closer->getLength(),
|
// getDelimiterUse() only ever asks whether the closer's length is >= 2, so lengths
|
||||||
|
// beyond that are interchangeable. Clamping keeps the key space bounded, which is
|
||||||
|
// what makes the delimiter stack's lower-bound cache amortize.
|
||||||
|
\min($closer->getLength(), 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ final class FencedCodeStartParser implements BlockStartParserInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
$indent = $cursor->getIndent();
|
$indent = $cursor->getIndent();
|
||||||
$fence = $cursor->match('/^[ \t]*(?:`{3,}(?!.*`)|~{3,})/');
|
$fence = $cursor->matchInPlace('/\G[ \t]*(?:`{3,}+(?!.*`)|~{3,})/');
|
||||||
if ($fence === null) {
|
if ($fence === null) {
|
||||||
return BlockStart::none();
|
return BlockStart::none();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,13 @@ final class BacktickParser implements InlineParserInterface
|
||||||
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
|
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
|
||||||
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
|
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
|
||||||
|
|
||||||
$c = \preg_replace('/\n/m', ' ', $code) ?? '';
|
$c = \str_replace("\n", ' ', $code);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$c !== '' &&
|
$c !== '' &&
|
||||||
$c[0] === ' ' &&
|
$c[0] === ' ' &&
|
||||||
\substr($c, -1, 1) === ' ' &&
|
\substr($c, -1, 1) === ' ' &&
|
||||||
\preg_match('/[^ ]/', $c)
|
\strspn($c, ' ') !== \strlen($c)
|
||||||
) {
|
) {
|
||||||
$c = \substr($c, 1, -1);
|
$c = \substr($c, 1, -1);
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +110,7 @@ final class BacktickParser implements InlineParserInterface
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) {
|
while ($ticks = $cursor->matchInPlace('/`{1,' . self::MAX_BACKTICKS . '}/')) {
|
||||||
$numTicks = \strlen($ticks);
|
$numTicks = \strlen($ticks);
|
||||||
|
|
||||||
// Did we find the closer?
|
// Did we find the closer?
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,16 @@ final class CloseBracketParser implements InlineParserInterface, EnvironmentAwar
|
||||||
// The reference must not contain a bracket. If we know there's a bracket, we don't even bother checking it.
|
// The reference must not contain a bracket. If we know there's a bracket, we don't even bother checking it.
|
||||||
$start = $opener->getPosition();
|
$start = $opener->getPosition();
|
||||||
$length = $startPos - $start;
|
$length = $startPos - $start;
|
||||||
|
|
||||||
|
// spec: A link label can have at most 999 characters inside the square brackets.
|
||||||
|
// ReferenceParser::parseLabel() enforces that when storing definitions, so a longer
|
||||||
|
// span here cannot possibly match one. Rejecting it up-front - before copying and
|
||||||
|
// normalizing the span - is what keeps deeply-nested brackets from being quadratic.
|
||||||
|
if ($length > 999) {
|
||||||
|
$cursor->restoreState($savePos);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$cursor->restoreState($savePos);
|
$cursor->restoreState($savePos);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,29 @@ namespace League\CommonMark\Extension\DefaultAttributes;
|
||||||
|
|
||||||
use League\CommonMark\Event\DocumentParsedEvent;
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
|
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
|
||||||
|
use League\CommonMark\Node\Node;
|
||||||
use League\Config\ConfigurationAwareInterface;
|
use League\Config\ConfigurationAwareInterface;
|
||||||
use League\Config\ConfigurationInterface;
|
use League\Config\ConfigurationInterface;
|
||||||
|
use League\Config\Exception\InvalidConfigurationException;
|
||||||
|
|
||||||
final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterface
|
final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterface
|
||||||
{
|
{
|
||||||
private ConfigurationInterface $config;
|
private ConfigurationInterface $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configured attributes with every callback already resolved to a `Closure`, keyed by node FQCN.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, mixed>>|null
|
||||||
|
*/
|
||||||
|
private ?array $resolved = null;
|
||||||
|
|
||||||
public function onDocumentParsed(DocumentParsedEvent $event): void
|
public function onDocumentParsed(DocumentParsedEvent $event): void
|
||||||
{
|
{
|
||||||
/** @var array<string, array<string, mixed>> $map */
|
if ($this->resolved === null) {
|
||||||
$map = $this->config->get('default_attributes');
|
$this->resolved = $this->resolveConfiguredAttributes();
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = $this->resolved;
|
||||||
|
|
||||||
// Don't bother iterating if no default attributes are configured
|
// Don't bother iterating if no default attributes are configured
|
||||||
if (! $map) {
|
if (! $map) {
|
||||||
|
|
@ -40,7 +52,7 @@ final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterfa
|
||||||
|
|
||||||
$newAttributes = [];
|
$newAttributes = [];
|
||||||
foreach ($attributesToApply as $name => $value) {
|
foreach ($attributesToApply as $name => $value) {
|
||||||
if (\is_callable($value)) {
|
if ($value instanceof \Closure) {
|
||||||
$value = $value($node);
|
$value = $value($node);
|
||||||
// Callables are allowed to return `null` indicating that no changes should be made
|
// Callables are allowed to return `null` indicating that no changes should be made
|
||||||
if ($value !== null) {
|
if ($value !== null) {
|
||||||
|
|
@ -62,4 +74,78 @@ final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterfa
|
||||||
{
|
{
|
||||||
$this->config = $configuration;
|
$this->config = $configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function resolveConfiguredAttributes(): array
|
||||||
|
{
|
||||||
|
/** @var array<string, array<string, mixed>> $map */
|
||||||
|
$map = $this->config->get('default_attributes/attributes');
|
||||||
|
$strict = (bool) $this->config->get('default_attributes/strict_callables');
|
||||||
|
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($map as $class => $attributes) {
|
||||||
|
foreach ($attributes as $name => $value) {
|
||||||
|
$resolved[$class][$name] = self::resolveValue($class, $name, $value, $strict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*
|
||||||
|
* @return mixed A `Closure` if the value is a callback, otherwise the literal attribute value
|
||||||
|
*/
|
||||||
|
private static function resolveValue(string $class, string $name, $value, bool $strict)
|
||||||
|
{
|
||||||
|
if ($value instanceof \Closure) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_string($value) || \is_array($value)) {
|
||||||
|
// Strings and arrays are how literal attribute values are written, and plenty of them also
|
||||||
|
// name a real function - PHP has a global `link()`, for example
|
||||||
|
if ($strict) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! \is_callable($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::wrapCallable($class, $name, $value, \Closure::fromCallable($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return \is_callable($value) ? \Closure::fromCallable($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the callable, replacing a failure with one which names the configuration responsible.
|
||||||
|
*
|
||||||
|
* A value like `'link'` is otherwise invoked and fails deep inside whichever function it collided
|
||||||
|
* with, reporting an argument count the user never wrote.
|
||||||
|
*
|
||||||
|
* @param string|mixed[] $value The callable as it was written in the configuration
|
||||||
|
*/
|
||||||
|
private static function wrapCallable(string $class, string $name, $value, \Closure $callback): \Closure
|
||||||
|
{
|
||||||
|
return static function (Node $node) use ($callback, $class, $name, $value) {
|
||||||
|
try {
|
||||||
|
return $callback($node);
|
||||||
|
} catch (\TypeError $e) {
|
||||||
|
throw new InvalidConfigurationException(\sprintf(
|
||||||
|
'The "%s" attribute configured for %s is %s, which PHP also treats as a callable, so it was '
|
||||||
|
. 'called with the node and failed: %s. If it was meant to be a literal attribute value instead, '
|
||||||
|
. 'set "default_attributes/strict_callables" to true.',
|
||||||
|
$name,
|
||||||
|
$class,
|
||||||
|
\is_string($value) ? \sprintf('the string "%s"', $value) : 'an array',
|
||||||
|
$e->getMessage()
|
||||||
|
), 0, $e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,52 @@ use Nette\Schema\Expect;
|
||||||
|
|
||||||
final class DefaultAttributesExtension implements ConfigurableExtensionInterface
|
final class DefaultAttributesExtension implements ConfigurableExtensionInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Keys belonging to this extension's own settings rather than to a node class
|
||||||
|
*/
|
||||||
|
private const RESERVED_KEYS = ['attributes' => 0, 'strict_callables' => 0];
|
||||||
|
|
||||||
public function configureSchema(ConfigurationBuilderInterface $builder): void
|
public function configureSchema(ConfigurationBuilderInterface $builder): void
|
||||||
{
|
{
|
||||||
$builder->addSchema('default_attributes', Expect::arrayOf(
|
$builder->addSchema('default_attributes', Expect::structure([
|
||||||
Expect::arrayOf(
|
'attributes' => Expect::arrayOf(
|
||||||
Expect::type('string|string[]|bool|callable'), // attribute value(s)
|
Expect::arrayOf(
|
||||||
'string' // attribute name
|
Expect::type('string|string[]|bool|callable'), // attribute value(s)
|
||||||
),
|
'string' // attribute name
|
||||||
'string' // node FQCN
|
),
|
||||||
)->default([]));
|
'string' // node FQCN
|
||||||
|
)->default([]),
|
||||||
|
// @deprecated This option will be removed in 3.0, when only closures and invokable objects
|
||||||
|
// will ever be treated as callbacks.
|
||||||
|
'strict_callables' => Expect::bool()->default(true),
|
||||||
|
])->before(static function ($value) {
|
||||||
|
if (! \is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$canonical = \array_intersect_key($value, self::RESERVED_KEYS);
|
||||||
|
$legacy = \array_diff_key($value, self::RESERVED_KEYS);
|
||||||
|
|
||||||
|
if ($legacy === []) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing can be merged into an `attributes` which isn't an array; hand it to the schema
|
||||||
|
// as-is so it reports the same validation error it would without the node classes present
|
||||||
|
if (isset($canonical['attributes']) && ! \is_array($canonical['attributes'])) {
|
||||||
|
return $canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
$canonical['attributes'] = \array_merge($legacy, $canonical['attributes'] ?? []);
|
||||||
|
|
||||||
|
// The flat shape predates this option, so it stays on the legacy callable handling
|
||||||
|
// unless it opts in explicitly.
|
||||||
|
if (! \array_key_exists('strict_callables', $canonical)) {
|
||||||
|
$canonical['strict_callables'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $canonical;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(EnvironmentBuilderInterface $environment): void
|
public function register(EnvironmentBuilderInterface $environment): void
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ final class DescriptionStartParser implements BlockStartParserInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
$cursor->advanceToNextNonSpaceOrTab();
|
$cursor->advanceToNextNonSpaceOrTab();
|
||||||
if ($cursor->match('/^:[ \t]+/') === null) {
|
if ($cursor->matchInPlace('/\G:[ \t]+/') === null) {
|
||||||
return BlockStart::none();
|
return BlockStart::none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ final class FrontMatterParser implements FrontMatterParserInterface
|
||||||
/** @psalm-readonly */
|
/** @psalm-readonly */
|
||||||
private FrontMatterDataParserInterface $frontMatterParser;
|
private FrontMatterDataParserInterface $frontMatterParser;
|
||||||
|
|
||||||
private const REGEX_FRONT_MATTER = '/^---\\R.*?\\R---\\R/s';
|
private const REGEX_FRONT_MATTER = '/\G---\\R.*?\\R---\\R/s';
|
||||||
|
|
||||||
public function __construct(FrontMatterDataParserInterface $frontMatterParser)
|
public function __construct(FrontMatterDataParserInterface $frontMatterParser)
|
||||||
{
|
{
|
||||||
|
|
@ -38,7 +38,7 @@ final class FrontMatterParser implements FrontMatterParserInterface
|
||||||
$cursor = new Cursor($markdownContent);
|
$cursor = new Cursor($markdownContent);
|
||||||
|
|
||||||
// Locate the front matter
|
// Locate the front matter
|
||||||
$frontMatter = $cursor->match(self::REGEX_FRONT_MATTER);
|
$frontMatter = $cursor->matchInPlace(self::REGEX_FRONT_MATTER);
|
||||||
if ($frontMatter === null) {
|
if ($frontMatter === null) {
|
||||||
return new MarkdownInputWithFrontMatter($markdownContent);
|
return new MarkdownInputWithFrontMatter($markdownContent);
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ final class FrontMatterParser implements FrontMatterParserInterface
|
||||||
$data = $this->frontMatterParser->parse($frontMatter);
|
$data = $this->frontMatterParser->parse($frontMatter);
|
||||||
|
|
||||||
// Advance through any remaining newlines which separated the front matter from the Markdown text
|
// Advance through any remaining newlines which separated the front matter from the Markdown text
|
||||||
$trailingNewlines = $cursor->match('/^\R+/');
|
$trailingNewlines = $cursor->matchInPlace('/\G\R+/');
|
||||||
|
|
||||||
// Calculate how many lines the Markdown is offset from the front matter by counting the number of newlines
|
// Calculate how many lines the Markdown is offset from the front matter by counting the number of newlines
|
||||||
// Don't forget to add 1 because we stripped one out when trimming the trailing delims
|
// Don't forget to add 1 because we stripped one out when trimming the trailing delims
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ final class HeadingPermalinkRenderer implements NodeRendererInterface, XmlNodeRe
|
||||||
$hidden = $this->config->get('heading_permalink/aria_hidden');
|
$hidden = $this->config->get('heading_permalink/aria_hidden');
|
||||||
if ($hidden) {
|
if ($hidden) {
|
||||||
$attrs->set('aria-hidden', 'true');
|
$attrs->set('aria-hidden', 'true');
|
||||||
|
// A focusable element removed from the accessibility tree is a WCAG 4.1.2 failure,
|
||||||
|
// so the link must also be taken out of the tab order
|
||||||
|
$attrs->set('tabindex', '-1');
|
||||||
}
|
}
|
||||||
|
|
||||||
$attrs->set('title', $this->config->get('heading_permalink/title'));
|
$attrs->set('title', $this->config->get('heading_permalink/title'));
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ class MarkDelimiterProcessor implements CacheableDelimiterProcessorInterface
|
||||||
|
|
||||||
public function getCacheKey(DelimiterInterface $closer): string
|
public function getCacheKey(DelimiterInterface $closer): string
|
||||||
{
|
{
|
||||||
return '=' . $closer->getLength();
|
// getDelimiterUse() returns 0 for every possible opener once the closer exceeds 2
|
||||||
|
// characters, so all longer closers behave identically and can share a bucket.
|
||||||
|
// Clamping keeps the key space bounded, which is what makes the delimiter stack's
|
||||||
|
// lower-bound cache amortize.
|
||||||
|
return '=' . \min($closer->getLength(), 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ final class StrikethroughDelimiterProcessor implements CacheableDelimiterProcess
|
||||||
|
|
||||||
public function getCacheKey(DelimiterInterface $closer): string
|
public function getCacheKey(DelimiterInterface $closer): string
|
||||||
{
|
{
|
||||||
return '~' . $closer->getLength();
|
// getDelimiterUse() returns 0 for every possible opener once the closer exceeds 2
|
||||||
|
// characters, so all longer closers behave identically and can share a bucket.
|
||||||
|
// Clamping keeps the key space bounded, which is what makes the delimiter stack's
|
||||||
|
// lower-bound cache amortize.
|
||||||
|
return '~' . \min($closer->getLength(), 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ final class TableStartParser implements BlockStartParserInterface
|
||||||
$cursor->advanceBy(1);
|
$cursor->advanceBy(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cursor->match('/^-+/') === null) {
|
if ($cursor->matchInPlace('/\G-+/') === null) {
|
||||||
// Need at least one dash
|
// Need at least one dash
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php
vendored
Normal file
47
vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the league/commonmark package.
|
||||||
|
*
|
||||||
|
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace League\CommonMark\Extension\TableOfContents\Node;
|
||||||
|
|
||||||
|
use League\CommonMark\Extension\TableOfContents\TableOfContentsRenderCache;
|
||||||
|
use League\CommonMark\Node\Block\AbstractBlock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight stand-in for a placeholder which references the shared table of contents instead of holding a full copy
|
||||||
|
*/
|
||||||
|
final class TableOfContentsReference extends AbstractBlock
|
||||||
|
{
|
||||||
|
/** @psalm-readonly */
|
||||||
|
private TableOfContents $tableOfContents;
|
||||||
|
|
||||||
|
/** @psalm-readonly */
|
||||||
|
private TableOfContentsRenderCache $renderCache;
|
||||||
|
|
||||||
|
public function __construct(TableOfContents $tableOfContents, TableOfContentsRenderCache $renderCache)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$this->tableOfContents = $tableOfContents;
|
||||||
|
$this->renderCache = $renderCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTableOfContents(): TableOfContents
|
||||||
|
{
|
||||||
|
return $this->tableOfContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRenderCache(): TableOfContentsRenderCache
|
||||||
|
{
|
||||||
|
return $this->renderCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,13 @@ declare(strict_types=1);
|
||||||
namespace League\CommonMark\Extension\TableOfContents;
|
namespace League\CommonMark\Extension\TableOfContents;
|
||||||
|
|
||||||
use League\CommonMark\Event\DocumentParsedEvent;
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
use League\CommonMark\Event\DocumentPreRenderEvent;
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
|
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||||
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink;
|
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink;
|
||||||
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
|
||||||
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
|
||||||
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsReference;
|
||||||
use League\CommonMark\Node\Block\Document;
|
use League\CommonMark\Node\Block\Document;
|
||||||
use League\CommonMark\Node\NodeIterator;
|
use League\CommonMark\Node\NodeIterator;
|
||||||
use League\Config\ConfigurationAwareInterface;
|
use League\Config\ConfigurationAwareInterface;
|
||||||
|
|
@ -89,16 +92,66 @@ final class TableOfContentsBuilder implements ConfigurationAwareInterface
|
||||||
|
|
||||||
private function replacePlaceholders(Document $document, TableOfContents $toc): void
|
private function replacePlaceholders(Document $document, TableOfContents $toc): void
|
||||||
{
|
{
|
||||||
|
$maxEntries = $this->config->get('table_of_contents/max_placeholder_entries');
|
||||||
|
\assert(\is_int($maxEntries) || $maxEntries === null);
|
||||||
|
$perCopy = $maxEntries === null ? 0 : self::countEntries($toc);
|
||||||
|
$cache = new TableOfContentsRenderCache();
|
||||||
|
$entries = 0;
|
||||||
|
$anchored = false;
|
||||||
|
|
||||||
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
|
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
|
||||||
// Add the block once we find a placeholder
|
// Add the block once we find a placeholder
|
||||||
if (! $node instanceof TableOfContentsPlaceholder) {
|
if (! $node instanceof TableOfContentsPlaceholder) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$node->replaceWith(clone $toc);
|
// Leave any remaining placeholders as-is once the entry budget is spent
|
||||||
|
if ($maxEntries !== null && $entries + $perCopy > $maxEntries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first placeholder receives the table of contents itself, so that it remains
|
||||||
|
// part of the document; the rest share the single result of rendering it
|
||||||
|
if ($anchored) {
|
||||||
|
$node->replaceWith(new TableOfContentsReference($toc, $cache));
|
||||||
|
} else {
|
||||||
|
$node->replaceWith($toc);
|
||||||
|
$anchored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries += $perCopy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function onDocumentPreRender(DocumentPreRenderEvent $event): void
|
||||||
|
{
|
||||||
|
// The shared references are an HTML rendering optimization; other formats
|
||||||
|
// (like XML) walk the node tree directly and expect complete copies
|
||||||
|
if ($event->getFormat() === 'html') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($event->getDocument()->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
|
||||||
|
if (! $node instanceof TableOfContentsReference) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->replaceWith(clone $node->getTableOfContents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function countEntries(TableOfContents $toc): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($toc->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
|
||||||
|
if ($node instanceof ListItem) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
public function setConfiguration(ConfigurationInterface $configuration): void
|
public function setConfiguration(ConfigurationInterface $configuration): void
|
||||||
{
|
{
|
||||||
$this->config = $configuration;
|
$this->config = $configuration;
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ namespace League\CommonMark\Extension\TableOfContents;
|
||||||
|
|
||||||
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||||
use League\CommonMark\Event\DocumentParsedEvent;
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
use League\CommonMark\Event\DocumentPreRenderEvent;
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
|
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
|
||||||
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListBlockRenderer;
|
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListBlockRenderer;
|
||||||
use League\CommonMark\Extension\ConfigurableExtensionInterface;
|
use League\CommonMark\Extension\ConfigurableExtensionInterface;
|
||||||
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
|
||||||
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
|
||||||
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsReference;
|
||||||
use League\Config\ConfigurationBuilderInterface;
|
use League\Config\ConfigurationBuilderInterface;
|
||||||
use Nette\Schema\Expect;
|
use Nette\Schema\Expect;
|
||||||
|
|
||||||
|
|
@ -35,19 +37,26 @@ final class TableOfContentsExtension implements ConfigurableExtensionInterface
|
||||||
'max_heading_level' => Expect::int()->min(1)->max(6)->default(6),
|
'max_heading_level' => Expect::int()->min(1)->max(6)->default(6),
|
||||||
'html_class' => Expect::string()->default('table-of-contents'),
|
'html_class' => Expect::string()->default('table-of-contents'),
|
||||||
'placeholder' => Expect::anyOf(Expect::string(), Expect::null())->default(null),
|
'placeholder' => Expect::anyOf(Expect::string(), Expect::null())->default(null),
|
||||||
|
'max_placeholder_entries' => Expect::anyOf(Expect::int()->min(0), Expect::null())->default(null),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(EnvironmentBuilderInterface $environment): void
|
public function register(EnvironmentBuilderInterface $environment): void
|
||||||
{
|
{
|
||||||
|
$builder = new TableOfContentsBuilder();
|
||||||
|
|
||||||
$environment->addRenderer(TableOfContents::class, new TableOfContentsRenderer(new ListBlockRenderer()));
|
$environment->addRenderer(TableOfContents::class, new TableOfContentsRenderer(new ListBlockRenderer()));
|
||||||
$environment->addEventListener(DocumentParsedEvent::class, [new TableOfContentsBuilder(), 'onDocumentParsed'], -150);
|
$environment->addEventListener(DocumentParsedEvent::class, [$builder, 'onDocumentParsed'], -150);
|
||||||
|
|
||||||
// phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
|
// phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
|
||||||
if ($environment->getConfiguration()->get('table_of_contents/position') === TableOfContentsBuilder::POSITION_PLACEHOLDER) {
|
if ($environment->getConfiguration()->get('table_of_contents/position') === TableOfContentsBuilder::POSITION_PLACEHOLDER) {
|
||||||
$environment->addBlockStartParser(TableOfContentsPlaceholderParser::blockStartParser(), 200);
|
$environment->addBlockStartParser(TableOfContentsPlaceholderParser::blockStartParser(), 200);
|
||||||
// If a placeholder cannot be replaced with a TOC element this renderer will ensure the parser won't error out
|
// If a placeholder cannot be replaced with a TOC element this renderer will ensure the parser won't error out
|
||||||
$environment->addRenderer(TableOfContentsPlaceholder::class, new TableOfContentsPlaceholderRenderer());
|
$environment->addRenderer(TableOfContentsPlaceholder::class, new TableOfContentsPlaceholderRenderer());
|
||||||
|
// Placeholders become lightweight references to a single shared TOC which is only rendered once
|
||||||
|
$environment->addRenderer(TableOfContentsReference::class, new TableOfContentsReferenceRenderer());
|
||||||
|
// Non-HTML formats walk the node tree directly, so expand the references back into complete copies there
|
||||||
|
$environment->addEventListener(DocumentPreRenderEvent::class, [$builder, 'onDocumentPreRender']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ final class TableOfContentsPlaceholderParser extends AbstractBlockContinueParser
|
||||||
}
|
}
|
||||||
|
|
||||||
// The placeholder must be the only thing on the line
|
// The placeholder must be the only thing on the line
|
||||||
if ($cursor->match('/^' . \preg_quote($placeholder, '/') . '$/') === null) {
|
if ($cursor->matchInPlace('/\G' . \preg_quote($placeholder, '/') . '$/') === null) {
|
||||||
return BlockStart::none();
|
return BlockStart::none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
44
vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php
vendored
Normal file
44
vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the league/commonmark package.
|
||||||
|
*
|
||||||
|
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace League\CommonMark\Extension\TableOfContents;
|
||||||
|
|
||||||
|
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsReference;
|
||||||
|
use League\CommonMark\Node\Node;
|
||||||
|
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||||
|
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||||
|
|
||||||
|
final class TableOfContentsReferenceRenderer implements NodeRendererInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param TableOfContentsReference $node
|
||||||
|
*
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @psalm-suppress MoreSpecificImplementedParamType
|
||||||
|
*/
|
||||||
|
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
|
||||||
|
{
|
||||||
|
TableOfContentsReference::assertInstanceOf($node);
|
||||||
|
|
||||||
|
$cache = $node->getRenderCache();
|
||||||
|
|
||||||
|
$html = $cache->getHtml();
|
||||||
|
if ($html === null) {
|
||||||
|
$html = $childRenderer->renderNodes([$node->getTableOfContents()]);
|
||||||
|
$cache->setHtml($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php
vendored
Normal file
34
vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the league/commonmark package.
|
||||||
|
*
|
||||||
|
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace League\CommonMark\Extension\TableOfContents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches the rendered HTML of a document's table of contents so all placeholders can share a single render
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TableOfContentsRenderCache
|
||||||
|
{
|
||||||
|
private ?string $html = null;
|
||||||
|
|
||||||
|
public function getHtml(): ?string
|
||||||
|
{
|
||||||
|
return $this->html;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHtml(string $html): void
|
||||||
|
{
|
||||||
|
$this->html = $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,17 +89,13 @@ final class AdjacentTextMerger
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$s = $first->getLiteral();
|
|
||||||
|
|
||||||
$node = $first->next();
|
$node = $first->next();
|
||||||
$stop = $last->next();
|
$stop = $last->next();
|
||||||
while ($node !== $stop && $node instanceof Text) {
|
while ($node !== $stop && $node instanceof Text) {
|
||||||
$s .= $node->getLiteral();
|
$first->append($node->getLiteral());
|
||||||
$unlink = $node;
|
$unlink = $node;
|
||||||
$node = $node->next();
|
$node = $node->next();
|
||||||
$unlink->detach();
|
$unlink->detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
$first->setLiteral($s);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
vendor/league/commonmark/src/Node/Node.php
vendored
12
vendor/league/commonmark/src/Node/Node.php
vendored
|
|
@ -21,7 +21,7 @@ use League\CommonMark\Exception\InvalidArgumentException;
|
||||||
|
|
||||||
abstract class Node
|
abstract class Node
|
||||||
{
|
{
|
||||||
/** @psalm-readonly */
|
/** @psalm-readonly-allow-private-mutation */
|
||||||
public Data $data;
|
public Data $data;
|
||||||
|
|
||||||
/** @psalm-readonly-allow-private-mutation */
|
/** @psalm-readonly-allow-private-mutation */
|
||||||
|
|
@ -243,14 +243,18 @@ abstract class Node
|
||||||
$this->parent = null;
|
$this->parent = null;
|
||||||
$this->previous = null;
|
$this->previous = null;
|
||||||
$this->next = null;
|
$this->next = null;
|
||||||
// But save a copy of the children since we'll need that in a moment
|
// But save a copy of the children since we'll need that in a moment.
|
||||||
$children = $this->children();
|
// Those children still belong to the node being cloned, so only this copy's own links may be dropped.
|
||||||
$this->detachChildren();
|
$children = $this->children();
|
||||||
|
$this->firstChild = $this->lastChild = null;
|
||||||
|
|
||||||
// The original children get cloned and re-added
|
// The original children get cloned and re-added
|
||||||
foreach ($children as $child) {
|
foreach ($children as $child) {
|
||||||
$this->appendChild(clone $child);
|
$this->appendChild(clone $child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The data belongs to a single node, so the two nodes each need their own copy
|
||||||
|
$this->data = clone $this->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function assertInstanceOf(Node $node): void
|
public static function assertInstanceOf(Node $node): void
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,40 @@ namespace League\CommonMark\Normalizer;
|
||||||
final class UniqueSlugNormalizer implements UniqueSlugNormalizerInterface
|
final class UniqueSlugNormalizer implements UniqueSlugNormalizerInterface
|
||||||
{
|
{
|
||||||
private TextNormalizerInterface $innerNormalizer;
|
private TextNormalizerInterface $innerNormalizer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs claimed by the surrounding page (in final, normalized form), seeded as if they'd
|
||||||
|
* already been handed out once, so the first colliding slug gets a "-1" suffix.
|
||||||
|
* Unlike regular history, these survive clearHistory().
|
||||||
|
*
|
||||||
|
* @var array<string, int>
|
||||||
|
*/
|
||||||
|
private array $reserved = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every slug we've handed out, mapped to the next numeric suffix to try for it
|
* Every slug we've handed out, mapped to the next numeric suffix to try for it
|
||||||
*
|
*
|
||||||
* @var array<string, int>
|
* @var array<string, int>
|
||||||
*/
|
*/
|
||||||
private array $alreadyUsed = [];
|
private array $alreadyUsed;
|
||||||
|
|
||||||
public function __construct(TextNormalizerInterface $innerNormalizer)
|
/**
|
||||||
|
* @param iterable<string> $reservedSlugs
|
||||||
|
*/
|
||||||
|
public function __construct(TextNormalizerInterface $innerNormalizer, iterable $reservedSlugs = [])
|
||||||
{
|
{
|
||||||
$this->innerNormalizer = $innerNormalizer;
|
$this->innerNormalizer = $innerNormalizer;
|
||||||
|
|
||||||
|
foreach ($reservedSlugs as $slug) {
|
||||||
|
$this->reserved[$slug] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->alreadyUsed = $this->reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearHistory(): void
|
public function clearHistory(): void
|
||||||
{
|
{
|
||||||
$this->alreadyUsed = [];
|
$this->alreadyUsed = $this->reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
168
vendor/league/commonmark/src/Parser/Cursor.php
vendored
168
vendor/league/commonmark/src/Parser/Cursor.php
vendored
|
|
@ -170,6 +170,10 @@ class Cursor
|
||||||
* Return the single multibyte character at $index, caching the sliced string so
|
* Return the single multibyte character at $index, caching the sliced string so
|
||||||
* repeated reads of the same position (common during delimiter scanning) don't
|
* repeated reads of the same position (common during delimiter scanning) don't
|
||||||
* re-slice. Callers must ensure 0 <= $index < $length.
|
* re-slice. Callers must ensure 0 <= $index < $length.
|
||||||
|
*
|
||||||
|
* Only call this when $isMultibyte is true. On a single-byte line the character is
|
||||||
|
* already reachable as $this->line[$index], which costs less than the byte-offset
|
||||||
|
* translation below; every caller guards on that flag for exactly that reason.
|
||||||
*/
|
*/
|
||||||
private function charAt(int $index): string
|
private function charAt(int $index): string
|
||||||
{
|
{
|
||||||
|
|
@ -179,7 +183,13 @@ class Cursor
|
||||||
|
|
||||||
$startByte = $this->byteOffset($index);
|
$startByte = $this->byteOffset($index);
|
||||||
|
|
||||||
return $this->charCache[$index] = \substr($this->line, $startByte, $this->byteOffset($index + 1) - $startByte);
|
// The line is known to be valid UTF-8 (the constructor rejects anything else), so the
|
||||||
|
// lead byte alone gives the character's width. Deriving it here avoids a second
|
||||||
|
// byteOffset() walk just to locate where the next character begins.
|
||||||
|
$lead = \ord($this->line[$startByte]);
|
||||||
|
$width = $lead < 0x80 ? 1 : ($lead < 0xE0 ? 2 : ($lead < 0xF0 ? 3 : 4));
|
||||||
|
|
||||||
|
return $this->charCache[$index] = \substr($this->line, $startByte, $width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -206,6 +216,15 @@ class Cursor
|
||||||
// byte, the character index and byte offset advance together.
|
// byte, the character index and byte offset advance together.
|
||||||
$byteOffset = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
|
$byteOffset = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
|
||||||
|
|
||||||
|
// Past the last tab (or on a line with none) every whitespace character is a space worth
|
||||||
|
// exactly one column, so the run length is both the character count and the indent, and
|
||||||
|
// strspn() can measure it in one call instead of a per-character loop.
|
||||||
|
if ($this->lastTabPosition === false || $this->currentPosition > $this->lastTabPosition) {
|
||||||
|
$this->indent = \strspn($this->line, ' ', $byteOffset);
|
||||||
|
|
||||||
|
return $this->nextNonSpaceCache = $this->currentPosition + $this->indent;
|
||||||
|
}
|
||||||
|
|
||||||
for ($i = $this->currentPosition; $i < $this->length; $i++, $byteOffset++) {
|
for ($i = $this->currentPosition; $i < $this->length; $i++, $byteOffset++) {
|
||||||
$c = $this->line[$byteOffset];
|
$c = $this->line[$byteOffset];
|
||||||
|
|
||||||
|
|
@ -450,13 +469,22 @@ class Cursor
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$matches = [];
|
// A partially-consumed tab leaves the cursor sitting on the tab itself, which the check
|
||||||
\preg_match('/^ *(?:\n *)?/', $this->getRemainder(), $matches, \PREG_OFFSET_CAPTURE);
|
// above has already returned on, so only real spaces and newlines reach this point and no
|
||||||
|
// tab expansion is needed.
|
||||||
|
//
|
||||||
|
// Spaces and newlines are single-byte ASCII characters which can never appear inside a
|
||||||
|
// multibyte UTF-8 sequence, so the run is measured at the byte level and each byte
|
||||||
|
// consumed is exactly one character. Scanning the line in place keeps the cost of each
|
||||||
|
// call proportional to the run it consumes, rather than to the length of everything left
|
||||||
|
// in the block, which is what building the remainder first charged for.
|
||||||
|
$byteOffset = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
|
||||||
|
|
||||||
// [0][0] contains the matched text
|
$increment = \strspn($this->line, ' ', $byteOffset);
|
||||||
// [0][1] contains the index of that match
|
if (($this->line[$byteOffset + $increment] ?? '') === "\n") {
|
||||||
\assert(isset($matches[0]));
|
$increment++;
|
||||||
$increment = $matches[0][1] + \strlen($matches[0][0]);
|
$increment += \strspn($this->line, ' ', $byteOffset + $increment);
|
||||||
|
}
|
||||||
|
|
||||||
$this->advanceBy($increment);
|
$this->advanceBy($increment);
|
||||||
|
|
||||||
|
|
@ -510,7 +538,12 @@ class Cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to match a regular expression
|
* Try to match a regular expression against the remainder of the line
|
||||||
|
*
|
||||||
|
* The subject begins at the cursor: text before the cursor is invisible to the pattern,
|
||||||
|
* so "^" and "\A" anchor at the cursor, and constructs which examine what precedes the
|
||||||
|
* match position (lookbehinds, "\b", "\B") see the start of a subject there rather than
|
||||||
|
* the characters actually preceding the cursor.
|
||||||
*
|
*
|
||||||
* Returns the matching text and advances to the end of that match
|
* Returns the matching text and advances to the end of that match
|
||||||
*
|
*
|
||||||
|
|
@ -518,23 +551,70 @@ class Cursor
|
||||||
*/
|
*/
|
||||||
public function match(string $regex): ?string
|
public function match(string $regex): ?string
|
||||||
{
|
{
|
||||||
// When a tab has been partially consumed the remainder is reconstructed with the
|
$subject = $this->getRemainder();
|
||||||
// leftover tab expanded into spaces, so matching must run against that reconstructed
|
|
||||||
// string rather than the raw line. This is rare; use the copy-based path to preserve
|
if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) {
|
||||||
// the exact column arithmetic.
|
return null;
|
||||||
if ($this->partiallyConsumedTab) {
|
|
||||||
return $this->matchViaRemainder($regex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match against the persistent line at the current byte offset instead of allocating a
|
// $matches[0][0] contains the matched text; $matches[0][1] is its byte offset in the subject.
|
||||||
// fresh copy of the remainder on every call. A leading "^" is rewritten to "\G" so the
|
if ($this->isMultibyte) {
|
||||||
// pattern still anchors to the cursor - a bare "^" only matches at the true start of the
|
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
|
||||||
// subject when a non-zero offset is supplied. Patterns that intentionally scan ahead
|
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
|
||||||
// (e.g. the backtick closer search) carry no leading "^" and are left untouched. This
|
} else {
|
||||||
// keeps repeated match() calls - such as that backtick scan - linear rather than O(n^2),
|
$offset = $matches[0][1];
|
||||||
// since each call no longer copies the entire remaining line.
|
$matchLength = \strlen($matches[0][0]);
|
||||||
if ($regex[1] === '^') {
|
}
|
||||||
$regex = $regex[0] . '\\G' . \substr($regex, 2);
|
|
||||||
|
$advance = $offset + $matchLength;
|
||||||
|
|
||||||
|
// The remainder we matched against had any partially-consumed tab expanded into spaces,
|
||||||
|
// so those columns must be advanced by column instead of by character.
|
||||||
|
if ($this->partiallyConsumedTab) {
|
||||||
|
$charsToTab = 4 - ($this->column % 4);
|
||||||
|
if ($advance < $charsToTab) {
|
||||||
|
$this->advanceBy($advance, true);
|
||||||
|
|
||||||
|
return $matches[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->advanceBy($charsToTab, true);
|
||||||
|
$advance -= $charsToTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->advanceBy($advance);
|
||||||
|
|
||||||
|
return $matches[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to match a regular expression at the cursor's position within the line, without
|
||||||
|
* copying the remainder
|
||||||
|
*
|
||||||
|
* Matches with PCRE's native offset semantics: the whole line is the subject, and matching
|
||||||
|
* starts at the cursor. "\G" anchors at the cursor; "^" anchors at the true start of the
|
||||||
|
* line (or after newlines under the "m" modifier); lookbehinds, "\b", and "\B" see the
|
||||||
|
* characters actually preceding the cursor. This differs from match(), whose subject begins
|
||||||
|
* at the cursor - a pattern written for match() migrates by replacing its leading "^" (or
|
||||||
|
* "\A") with "\G".
|
||||||
|
*
|
||||||
|
* Because no copy of the remainder is made, repeated calls stay linear: match() copies
|
||||||
|
* everything left in the line on every call, so scanning loops (such as the backtick closer
|
||||||
|
* search) would otherwise cost O(n^2).
|
||||||
|
*
|
||||||
|
* When a tab has been partially consumed, no position within the line can represent the
|
||||||
|
* cursor, so this falls back to matching the remainder with the leftover tab expanded into
|
||||||
|
* spaces; "\G" still anchors at the cursor there, but the line content before it is not
|
||||||
|
* visible in that case.
|
||||||
|
*
|
||||||
|
* @psalm-param non-empty-string $regex
|
||||||
|
*/
|
||||||
|
public function matchInPlace(string $regex): ?string
|
||||||
|
{
|
||||||
|
// A partially-consumed tab means the remainder differs from the underlying line (the
|
||||||
|
// leftover tab expands into spaces), so no byte offset can represent the cursor.
|
||||||
|
if ($this->partiallyConsumedTab) {
|
||||||
|
return $this->match($regex);
|
||||||
}
|
}
|
||||||
|
|
||||||
$bytePosition = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
|
$bytePosition = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
|
||||||
|
|
@ -560,48 +640,6 @@ class Cursor
|
||||||
return $matches[0][0];
|
return $matches[0][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Slow path for match() used only when a tab has been partially consumed: match against a
|
|
||||||
* freshly-built remainder whose leftover tab is expanded into spaces, advancing by columns
|
|
||||||
* across that expansion. Kept separate so the common case avoids the remainder allocation.
|
|
||||||
*
|
|
||||||
* @psalm-param non-empty-string $regex
|
|
||||||
*/
|
|
||||||
private function matchViaRemainder(string $regex): ?string
|
|
||||||
{
|
|
||||||
$subject = $this->getRemainder();
|
|
||||||
|
|
||||||
if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isMultibyte) {
|
|
||||||
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
|
|
||||||
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
|
|
||||||
} else {
|
|
||||||
$offset = $matches[0][1];
|
|
||||||
$matchLength = \strlen($matches[0][0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$advance = $offset + $matchLength;
|
|
||||||
|
|
||||||
// The remainder we matched against had the partially-consumed tab expanded into spaces,
|
|
||||||
// so those columns must be advanced by column instead of by character.
|
|
||||||
$charsToTab = 4 - ($this->column % 4);
|
|
||||||
if ($advance < $charsToTab) {
|
|
||||||
$this->advanceBy($advance, true);
|
|
||||||
|
|
||||||
return $matches[0][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advanceBy($charsToTab, true);
|
|
||||||
$advance -= $charsToTab;
|
|
||||||
|
|
||||||
$this->advanceBy($advance);
|
|
||||||
|
|
||||||
return $matches[0][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the current state of this cursor in case you need to rollback later.
|
* Encapsulates the current state of this cursor in case you need to rollback later.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ final class LinkParserHelper
|
||||||
|
|
||||||
public static function parseLinkLabel(Cursor $cursor): int
|
public static function parseLinkLabel(Cursor $cursor): int
|
||||||
{
|
{
|
||||||
$match = $cursor->match('/^\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
|
$match = $cursor->matchInPlace('/\G\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
|
||||||
if ($match === null) {
|
if ($match === null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ final class LinkParserHelper
|
||||||
|
|
||||||
public static function parsePartialLinkLabel(Cursor $cursor): ?string
|
public static function parsePartialLinkLabel(Cursor $cursor): ?string
|
||||||
{
|
{
|
||||||
return $cursor->match('/^(?:[^\\\\\[\]]++|\\\\.?)*+/');
|
return $cursor->matchInPlace('/\G(?:[^\\\\\[\]]++|\\\\.?)*+/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -72,7 +72,7 @@ final class LinkParserHelper
|
||||||
*/
|
*/
|
||||||
public static function parseLinkTitle(Cursor $cursor): ?string
|
public static function parseLinkTitle(Cursor $cursor): ?string
|
||||||
{
|
{
|
||||||
if ($title = $cursor->match('/' . RegexHelper::PARTIAL_LINK_TITLE . '/')) {
|
if ($title = $cursor->matchInPlace('/\G' . RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED . '/')) {
|
||||||
// Chop off quotes from title and unescape
|
// Chop off quotes from title and unescape
|
||||||
return RegexHelper::unescape(\substr($title, 1, -1));
|
return RegexHelper::unescape(\substr($title, 1, -1));
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ final class LinkParserHelper
|
||||||
{
|
{
|
||||||
$endDelimiter = \preg_quote($endDelimiter, '/');
|
$endDelimiter = \preg_quote($endDelimiter, '/');
|
||||||
$regex = \sprintf('/(%s|[^%s\x00])*(?:%s)?/', RegexHelper::PARTIAL_ESCAPED_CHAR, $endDelimiter, $endDelimiter);
|
$regex = \sprintf('/(%s|[^%s\x00])*(?:%s)?/', RegexHelper::PARTIAL_ESCAPED_CHAR, $endDelimiter, $endDelimiter);
|
||||||
if (($partialTitle = $cursor->match($regex)) === null) {
|
if (($partialTitle = $cursor->matchInPlace($regex)) === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,12 +93,19 @@ final class LinkParserHelper
|
||||||
|
|
||||||
private static function manuallyParseLinkDestination(Cursor $cursor): ?string
|
private static function manuallyParseLinkDestination(Cursor $cursor): ?string
|
||||||
{
|
{
|
||||||
$remainder = $cursor->getRemainder();
|
// The destination always ends at the first whitespace or unbalanced ")", so scan the line
|
||||||
|
// in place from the cursor rather than materializing the remainder: the cost of finding it
|
||||||
|
// should follow the length of the destination, not the length of everything left in the
|
||||||
|
// block. A partially-consumed tab needs no special handling - getRemainder() would expand
|
||||||
|
// it into leading spaces and the scan would stop on the first one, exactly as it stops on
|
||||||
|
// the tab itself here.
|
||||||
|
$line = $cursor->getLine();
|
||||||
|
$start = $cursor->getBytePosition();
|
||||||
$openParens = 0;
|
$openParens = 0;
|
||||||
$len = \strlen($remainder);
|
$len = \strlen($line) - $start;
|
||||||
for ($i = 0; $i < $len; $i++) {
|
for ($i = 0; $i < $len; $i++) {
|
||||||
$c = $remainder[$i];
|
$c = $line[$start + $i];
|
||||||
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) {
|
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($line[$start + $i + 1])) {
|
||||||
$i++;
|
$i++;
|
||||||
} elseif ($c === '(') {
|
} elseif ($c === '(') {
|
||||||
$openParens++;
|
$openParens++;
|
||||||
|
|
@ -125,7 +132,7 @@ final class LinkParserHelper
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$destination = \substr($remainder, 0, $i);
|
$destination = \substr($line, $start, $i);
|
||||||
$cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
|
$cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
|
|
@ -149,7 +156,7 @@ final class LinkParserHelper
|
||||||
self::$lastCursor = \WeakReference::create($cursor);
|
self::$lastCursor = \WeakReference::create($cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
|
if ($res = $cursor->matchInPlace('/\G' . RegexHelper::PARTIAL_LINK_DESTINATION_BRACES . '/')) {
|
||||||
self::$lastCursorLacksClosingBrace = false;
|
self::$lastCursorLacksClosingBrace = false;
|
||||||
|
|
||||||
// Chop off surrounding <..>:
|
// Chop off surrounding <..>:
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,15 @@ final class RegexHelper
|
||||||
self::PARTIAL_PROCESSINGINSTRUCTION . '|' . self::PARTIAL_DECLARATION . '|' . self::PARTIAL_CDATA . ')';
|
self::PARTIAL_PROCESSINGINSTRUCTION . '|' . self::PARTIAL_DECLARATION . '|' . self::PARTIAL_CDATA . ')';
|
||||||
public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s\/>]|$)' . '|' .
|
public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s\/>]|$)' . '|' .
|
||||||
'\/' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s>]|$)' . '|' . '[?!])';
|
'\/' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s>]|$)' . '|' . '[?!])';
|
||||||
public const PARTIAL_LINK_TITLE = '^(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*+"' .
|
/**
|
||||||
|
* Unanchored so each call site can supply its own anchor: "^" against a detached string,
|
||||||
|
* or "\G" at a cursor position (see Cursor::matchInPlace()).
|
||||||
|
*/
|
||||||
|
public const PARTIAL_LINK_TITLE_UNANCHORED = '(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*+"' .
|
||||||
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*+\'' .
|
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*+\'' .
|
||||||
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
|
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
|
||||||
|
/** @deprecated since 2.10; use {@link RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED} with an explicit anchor instead */
|
||||||
|
public const PARTIAL_LINK_TITLE = '^' . self::PARTIAL_LINK_TITLE_UNANCHORED;
|
||||||
|
|
||||||
public const REGEX_PUNCTUATION = '/^[\p{P}\p{S}]/u';
|
public const REGEX_PUNCTUATION = '/^[\p{P}\p{S}]/u';
|
||||||
public const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
|
public const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
|
||||||
|
|
@ -73,7 +79,13 @@ final class RegexHelper
|
||||||
public const REGEX_WHITESPACE_CHAR = '/^[ \t\n\x0b\x0c\x0d]/';
|
public const REGEX_WHITESPACE_CHAR = '/^[ \t\n\x0b\x0c\x0d]/';
|
||||||
public const REGEX_UNICODE_WHITESPACE_CHAR = '/^\pZ|\s/u';
|
public const REGEX_UNICODE_WHITESPACE_CHAR = '/^\pZ|\s/u';
|
||||||
public const REGEX_THEMATIC_BREAK = '/^(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$/';
|
public const REGEX_THEMATIC_BREAK = '/^(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$/';
|
||||||
public const REGEX_LINK_DESTINATION_BRACES = '/^(?:<(?:[^<>\\n\\\\\\x00]|\\\\.)*>)/';
|
/**
|
||||||
|
* Unanchored so each call site can supply its own anchor: "^" against a detached string,
|
||||||
|
* or "\G" at a cursor position (see Cursor::matchInPlace()).
|
||||||
|
*/
|
||||||
|
public const PARTIAL_LINK_DESTINATION_BRACES = '(?:<(?:[^<>\\n\\\\\\x00]|\\\\.)*>)';
|
||||||
|
/** @deprecated since 2.10; use {@link RegexHelper::PARTIAL_LINK_DESTINATION_BRACES} with an explicit anchor instead */
|
||||||
|
public const REGEX_LINK_DESTINATION_BRACES = '/^' . self::PARTIAL_LINK_DESTINATION_BRACES . '/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @psalm-pure
|
* @psalm-pure
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue