From 3a25585090423bab94c583a10c31e2f7e0fd4d27 Mon Sep 17 00:00:00 2001 From: Javier Casares Date: Mon, 17 Aug 2026 17:56:43 +0000 Subject: [PATCH] v1.2.1 --- changelog.txt | 39 ++ class-robotstxt-updater.php | 465 ------------------ .../robotstxt-documentation-markdown-ca.mo | Bin 12254 -> 13623 bytes .../robotstxt-documentation-markdown-es_ES.mo | Bin 12256 -> 13621 bytes readme.txt | 120 ++--- robotstxt-documentation-markdown.php | 83 +++- uninstall.php | 2 +- update.json | 10 +- vendor/composer/autoload_classmap.php | 3 + vendor/composer/autoload_static.php | 3 + vendor/composer/installed.json | 14 +- vendor/composer/installed.php | 6 +- vendor/league/commonmark/CHANGELOG.md | 68 ++- vendor/league/commonmark/composer.json | 2 +- .../src/Environment/Environment.php | 9 +- .../Attributes/Event/AttributesListener.php | 263 +++++++++- .../Parser/AttributesBlockContinueParser.php | 71 ++- .../Attributes/Util/AttributesHelper.php | 51 +- .../Processor/EmphasisDelimiterProcessor.php | 5 +- .../Parser/Block/FencedCodeStartParser.php | 2 +- .../Parser/Inline/BacktickParser.php | 6 +- .../Parser/Inline/CloseBracketParser.php | 10 + .../ApplyDefaultAttributesProcessor.php | 92 +++- .../DefaultAttributesExtension.php | 51 +- .../Parser/DescriptionStartParser.php | 2 +- .../FrontMatter/FrontMatterParser.php | 6 +- .../HeadingPermalinkRenderer.php | 3 + .../Highlight/MarkDelimiterProcessor.php | 6 +- .../StrikethroughDelimiterProcessor.php | 6 +- .../src/Extension/Table/TableStartParser.php | 2 +- .../Node/TableOfContentsReference.php | 47 ++ .../TableOfContentsBuilder.php | 55 ++- .../TableOfContentsExtension.php | 11 +- .../TableOfContentsPlaceholderParser.php | 2 +- .../TableOfContentsReferenceRenderer.php | 44 ++ .../TableOfContentsRenderCache.php | 34 ++ .../src/Node/Inline/AdjacentTextMerger.php | 6 +- vendor/league/commonmark/src/Node/Node.php | 12 +- .../src/Normalizer/UniqueSlugNormalizer.php | 25 +- .../league/commonmark/src/Parser/Cursor.php | 168 ++++--- .../commonmark/src/Util/LinkParserHelper.php | 27 +- .../commonmark/src/Util/RegexHelper.php | 16 +- 42 files changed, 1130 insertions(+), 717 deletions(-) delete mode 100644 class-robotstxt-updater.php create mode 100644 vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php create mode 100644 vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php create mode 100644 vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php diff --git a/changelog.txt b/changelog.txt index 5c888c7..e1013fc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,44 @@ == 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 = _Release date: 2026-08-07_ diff --git a/class-robotstxt-updater.php b/class-robotstxt-updater.php deleted file mode 100644 index 7b56a73..0000000 --- a/class-robotstxt-updater.php +++ /dev/null @@ -1,465 +0,0 @@ - - */ - 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 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 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 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 $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 $data Raw array from transient or API. - * @return array - */ - 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' ); - } - } -} diff --git a/languages/robotstxt-documentation-markdown-ca.mo b/languages/robotstxt-documentation-markdown-ca.mo index 26d5b8f388eb0e85f3c90cda16419ec6d9423528..b2230e212c64c23e72ec96f2e7ae61a3939d462e 100644 GIT binary patch delta 4932 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 diff --git a/languages/robotstxt-documentation-markdown-es_ES.mo b/languages/robotstxt-documentation-markdown-es_ES.mo index eb10019d5627af7807ff9c2b78d39aea469b78ef..95cde142b73ab3dff866f984c49ea441a9674f1c 100644 GIT binary patch delta 4929 zcma);3vd1UL6?l1*;zUU&Br0&TB~ zrM0&BxTr;%w#qnGZKpcHv9051!J<>Ow$-W~9qV+&(Xr#SqtmHQZTtOpH_=lq{{^5(8bx{7a9jJVw}`q4BrqTHAl;h#ryVO(^EF{i_&a4cK{N5Rbrx5M$= zx507Hhy0oAxLgWvhFbpu90^}Z?%#lOj47J8nM~ltc)p$oEyyFY9-alSfOFwBa56js z7r}>N3Z8=Jz)4t91M47<%nmpScEcukEyOqTJd{H(Ml2fhM<)8IB9N$GTKx z|7kop#RG-xeW)CL02R8Apx%$fyLK<--X&>Itf(dD^TtG zCR8Lpf%0@1VbFe~p^AATl*0{B>x=7{XoC#Yfjy8vvyaPScnE6YvrrLu2g-vFAb;jx zTvQ`d35N`8fJw=YRA#5IXDUSssHygQK&zG7sGnK(g8U*8}5Zs@jw}R z4DNuBL*?)stixHe9V*m5ltI_QHSj1@B;JKb;a{NE9il>qbG|vsLZ zGvb`y2X*kHP!W0(;*9w<)Vg<|Qt=^N3(Kwez$U0vv_N^@3YWm0P$|Cy))$%F&qO(S z9jYkag%`qcxIz7y)lk*G9WICcQ0v}+4RCl>9HC{9pqOUZ3A0e1{~qGBc|Cdm5tPA` z`H?9uWO6AxJRHeQ-MbB~<9%O71^^7WWlcqk|Sfeb)fBZUbbA*#YOk zYoQ`>KfDb7WESz42Y=_me7LAOwrm&F!Fi~nI|vo(!|-x=Z}R>VNYqW``Edj;hKk$< zsL1Vy7Ca2)z|W!1`2$q9mCq*r%KdmQ@_Y%DrCm@(Rf0PBK{yIN3Kgj*lIPDr)xt}V zY?$|<+H?Y&==p3&U7Hnfal>4C8-vJfUd!Y<`4$9M);A!wRsEE7;^+Po}6&p|mfnDuMPN={oSLOjMbre@({w`_d>Af=4<+ya3Jcs%SP&a_0IJOo!Xt1T zEq5_|0A8sVZ!?iaE2%(jyag(h*F%N+dr%ub0KWjOMX@1WPzKxxiN5&(+zFq9ipYF^ zR-b{3;3W7DI2=yAFz%Ykuvo@}ZnO<4oGOz4HrjaDfo?(AVO*pR(fBS>#MUE4s0VRY z)M6@PjXRK>(YQW_`Gx|c{vRA$n5;nZ5+|a<8UqzJT0I(HPPhr~LKmT4p@mA!?Mzmq-ALoi7(>@_%v4Ys%G<5TLf=LjUqe;VjDOVcr z(m;cmn^4q-P^s9A_MijGzs6nYD`+JOk?PE11HXl4p&QX;v=$AHJDGH${NMw4 z725ZidrccZb5Enh{#PascffV%5_A}qqZVYNsi+TS(O9$w?MK_uA=HU3MPEl6XP|GQ z61oiChOR&x(b*^-R8bAHH2Ny??k7gr{<trzh|Ew%=#9yIIF-_k7Fku{#{DJP%W5l~>4Steh8GZq5x|JL~RstgzFu zF8BOQv+o2!^kvW&uvMz`&Kc*TLN1dN6arUGgClf^L z7A~+htW1osvTlzXrb9XH6|D@+R%Ly9s6?^m^>jOGB~{fy)v^tC zj^O&%oVGq|^QM)XwzO<%-7>ea&R%BavxN>fXY~|<(BeEd7ld|Je%d*d4ml!aws_r6 z&e}#6VtbpsfrhDb%Zj-m-RWcsWKgTg%Ft~#Yo&cJXLWgPLCV@1D0yyRx$V*8zzRCO zUaM*&`5pWwQ^nzF&&RJY+m|w1b3q}mV!)OpmwkE1N>#Jpifq>Fbu!Z$tgd`V4Ht)N zKF6iQZLhIYx3@Rtdu?7AgnPnN;I)Unw(r!%{s(oX zC#G4`>&7&1s@?4Db_3VT)vhL*4Oabv`o*;iE~;I)WZ)N-r-l_fZ7c6(+>D1Oj_q58 zoMrpzPIotz7FTuF%G0LGv!-d#B6iP`x4aj)q363;SI9fq z&eQCPA5;F0&X1eYPP-=ta=MSYBvpEC&hgU8xhKzCOTl=SE%S3qy1N%|jz6d#IsRDO zB&D0?tt&>Ujhy2puMB2<`boTd9G#>)?2JcR9pB@JAdXvZF!of5?RO}+9wD+rT^&W* z4&07hY--B13>n1krRV3RD%Rve-_ArvtabuA)f@PD-m0=enG!FY~|$~ z?$u@|?xxRdI*j-vjr0HZS;nEeI1!WM9>Y1SFYYieHPBeoUshaY=h6;7^eH92*WugL zLg;01PHtj(o81+AsyM5!NL9dZT;N74qL%9^@W{7wq<}gZ+$ZB5e@r3brge?Q+k4Y9``pjDckjLDoadag`^AFm zvje}ji+Imab`o8PlM%+8#o|^RC{@oH(;62eAG4A}6mE3ffw7$L!8Z6I#^6^t2G61H zkK$q^#-h$UU@v0=CWT5I7jm!<&Okn9HHSppj=k{+w#T0^3GbpBjNp)f@tB9bum#qj zI=QlTg20`f6WIOxGBhDGO1Jkpl2umidsCm_i(#i;u{sH9b( zI=U1);TBYf4&yL9h8pM{Orw1h&+zqN7PiH4&Upbwb6$dDaXxCKM^KqLh3e=fbmMQR z=i72n!)e$VM`2f-iJF31=XxCml!|&P-S7;mBezg5{u7(yQ`Ce1qUNqeJ3AvWn9O-+ zq-`?{xy4LIKBkz1zOTfVxEYnPU8s!gZAbo}0Cy{KLpHWkF8`a^*s73e`)y{Czmx(#ZtIete@;`*ib}r}z-=R`|2{qCis0Qyk z*PAo3x~~nEU~++8@1Z*SC_shIg;y3;lfVkoBJ0CJ zsT_xDpa7M^d8lo=2$jl>sF7|(y?7UDG4Df-_#EoFi>L;FK|OyTHPApyR+3T}k6Hu$ zPz~p!R_h$pBHDus@i=PkQhBHrd*Kjlz!7*Em4SHD))Tv;-j{>w$V}uFW-gMkfLTUG z4R62+coa3Fhj9(tVhPAE4&)Dh|R2s1&EB*mIqZ8JriQrgEcmeLpG# zXHl8FjPvm^M$*11?rP6TDe8tg)STDjK>Qdrl541j@1Q!;*|N8n8+n(>LuG2AbG{7u znB5#SuoI}~zCqo05d&#dZc^!mQOtuzJOC$RCJx5{_QeyZj{S}d-u#7HMD0`Ulv?;I z=lxLMuR;218c-QHgUZlFREDC{$iGF!q8A#$I8;M(P}^k_YL0iIMt%y_(TAu-l*As> zTn<95{?VvRWjoiWpys>?>8q(h7QT7kxqhsB!2UC7CxjmSq1HCOvk4V*@e^d4%B{Db{5iG`w$O~kP{8}QD`?LG6Om z=)x*u9kGf~`=*@OOvDh%Ku_XrLi0107()ytEaFW5T7D(X`0YH#@~El+8w*XOhR^UHl! zm8Z<>4jxLL?iv_;mfS6=tlaOeuAE!x_xYFl-Cke%)G6bq6c!ZDC=8BFNsTP``D(nr z;H;ER5t;o53=J($x!o*yt6SI5=+wmM;Dx@egNOPZ4-W4CAeimW4#uVT4&|kfcZJ#v ptapVL4F2AgOoP>)a1Z8rt1XY^_4z$k)wc)A$}2qXU_wT>{{R%FVK4vy diff --git a/readme.txt b/readme.txt index 914f7e5..5c061f8 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === Documentation Markdown (by ROBOTSTXT) === -Contributors: robotstxt +Contributors: robotstxt, javiercasares Tags: github, documentation, markdown, sync, automation -Requires at least: 6.8 +Requires at least: 4.2 Tested up to: 7.1 Requires PHP: 8.0 -Stable tag: 1.2.0 +Stable tag: 1.2.1 License: GPLv3 or later 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 = * PHP 8.0 or higher -* WordPress 6.8 or higher +* WordPress 4.2 or higher * GitHub Personal Access Token (free, for accessing repositories) * 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 = * 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 == -* WordPress: 6.8 - 7.1 +* WordPress: 4.2 - 7.1 * PHP: 8.0 - 8.5 == 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 = @@ -246,93 +272,25 @@ For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTST * PHPUnit: 17 plugin header tests added * 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 == -= 1.0.0 = -Initial release of Documentation Markdown. Sync your GitHub Markdown files to WordPress automatically! += 1.2.1 = +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 == = Support = -* **Documentation:** Comprehensive docs included in `/docs/` directory -* **Repository:** [Report issues](https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/issues) -* **Website:** [ROBOTSTXT.es](https://www.robotstxt.es/) +* **Documentation:** [robotstxt.software](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/) +* **Updates:** [Manager (by ROBOTSTXT)](https://www.robotstxt.software/plugins/robotstxt-manager/) +* **Website:** [ROBOTSTXT.software](https://www.robotstxt.software/) * **Security:** robotstxt@robotstxt.es = 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 * Suggest features -* Submit pull requests = Privacy = @@ -340,7 +298,7 @@ This plugin does not collect or store any user data. The only external connectio = Credits = -Developed by ROBOTSTXT with ❤️ +Developed by [ROBOTSTXT](https://www.robotstxt.software/) with ❤️ **Dependencies:** * [league/commonmark](https://commonmark.thephpleague.com/) - Markdown parser and converter diff --git a/robotstxt-documentation-markdown.php b/robotstxt-documentation-markdown.php index fb22840..e52d538 100644 --- a/robotstxt-documentation-markdown.php +++ b/robotstxt-documentation-markdown.php @@ -1,20 +1,19 @@ + + + +
@@ -1671,6 +1675,65 @@ function robotstxt_docmd_debug_fix_crons() { exit; } -// Initialize ROBOTSTXT updater (auto-configures from plugin headers). -require_once __DIR__ . '/class-robotstxt-updater.php'; -Robotstxt_Updater::init( __FILE__ ); +/** + * Check whether the Manager (by ROBOTSTXT) plugin is active + * + * 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 '

'; + echo wp_kses( + sprintf( + /* translators: %s: Manager (by ROBOTSTXT) plugin URL. */ + __( 'To receive automatic updates, the Manager (by ROBOTSTXT) plugin must be installed and active.', 'robotstxt-documentation-markdown' ), + esc_url( 'https://www.robotstxt.software/plugins/robotstxt-manager/' ) + ), + array( + 'a' => array( + 'href' => true, + ), + ) + ); + echo '

'; +} + +/** + * 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 ); +} diff --git a/uninstall.php b/uninstall.php index 8ab635c..3810527 100644 --- a/uninstall.php +++ b/uninstall.php @@ -7,7 +7,7 @@ * @package RobotsTxt\DocumentationMarkdown * @author ROBOTSTXT * @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 */ diff --git a/update.json b/update.json index 2c08ea5..4fa49e4 100644 --- a/update.json +++ b/update.json @@ -1,8 +1,8 @@ { "name": "Documentation Markdown (by ROBOTSTXT)", "slug": "robotstxt-documentation-markdown", - "version": "1.2.0", - "download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/releases/download/1.2.0/robotstxt-documentation-markdown-1.2.0.zip", + "version": "1.2.1", + "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_php": "8.0", "tested": "7.1", @@ -11,12 +11,10 @@ "author_profile": "https://www.robotstxt.es/", "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.", - "changelog": "

1.2.0 - 2026-08-07

Added

  • Title from H1: synced post title taken from the first Markdown H1 (and stripped from the body)
  • Internal link translation: repo-relative links rewritten to the matching WordPress permalink
  • Image sideloading: repository images imported into the Media Library and referenced by attachment URL

Security

  • Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)
  • GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)
  • CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d

1.1.1 - 2026-06-08

Security

  • CommonMark raw HTML passthrough disabled; token option no longer autoloaded

1.0.0 - 2026-01-26

Initial Release

  • Core: GitHub repository synchronization system
  • Core: Markdown to HTML conversion using CommonMark
  • Core: Encrypted GitHub token storage (AES-256-CBC)
  • Feature: Flexible file-to-content mapping system
  • Feature: Custom Post Type for mapping management
  • Feature: Configurable sync frequency (manual, hourly, twice daily, daily)
  • Feature: Manual on-demand synchronization
  • Feature: Support for pages, posts, and custom post types
  • Feature: Page order (menu_order) configuration
  • Feature: Configurable post author and parent page
  • Admin: Complete admin interface with status monitoring
  • Admin: Settings page for GitHub configuration
  • Admin: Mappings management interface
  • Debug: Built-in debug tools (visible when WP_DEBUG enabled)
  • Debug: Test GitHub connection and token validity
  • Debug: View and manage scheduled cron jobs
  • Debug: Fix/reschedule broken cron jobs
  • Debug: Cache management tools
  • Security: Complete input sanitization and output escaping
  • Security: Nonce verification on all forms
  • Security: Capability checks for admin actions
  • i18n: Full internationalization support
  • Quality: WordPress Coding Standards compliant
", + "changelog": "", "sections": { "description": "

Documentation Markdown is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.

Key Features

  • Automatic Synchronization: Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)
  • Markdown to HTML: Convert GitHub Flavored Markdown to clean HTML using CommonMark
  • Flexible Mapping: Map individual MD files to specific WordPress posts or pages
  • Title from H1: Post titles are derived from the first Markdown H1 heading
  • Link & Image Translation: Repo-relative links are rewritten to permalinks; images are sideloaded into the Media Library
  • Secure: Encrypted GitHub token storage (HKDF-SHA256), full input validation & output escaping
  • Translatable: Full internationalization support (i18n/l10n ready)
  • Multi-Repository: Sync from multiple GitHub repos simultaneously
  • Manual Sync: On-demand synchronization from admin interface
  • Debug Tools: Built-in debugging tools (visible when WP_DEBUG is enabled)

Use Cases

  • API Documentation - Keep your API docs in sync between GitHub and WordPress
  • Technical Documentation - Maintain version-controlled technical docs
  • Knowledge Base - Build a knowledge base powered by GitHub
  • Blog Posts - Write blog posts in Markdown with Git workflow
  • Product Documentation - Sync product documentation from your repository

Requirements

  • PHP 8.0 or higher
  • WordPress 6.8 or higher
  • GitHub Personal Access Token (free)
", - "installation": "

Installation

  1. Upload the plugin files to /wp-content/plugins/robotstxt-documentation-markdown/
  2. Activate the plugin through the 'Plugins' menu in WordPress
  3. Navigate to 'Documentation → Settings' in the WordPress admin menu
  4. Generate a GitHub Personal Access Token:
    • Go to GitHub → Settings → Developer settings → Personal access tokens
    • Click 'Generate new token'
    • For public repositories: No specific scopes needed
    • For private repositories: Select repo scope
  5. Paste your token in the plugin settings and save
  6. Create your first mapping under 'Documentation → Mappings'

Configuration

  1. Go to Documentation → Add Mapping
  2. Fill in the repository details (owner, name, file path, branch)
  3. Configure target content (post type, author, parent, order)
  4. Set synchronization frequency
  5. Save and click 'Sync Now' to perform first sync
", - "faq": "

Do I need a GitHub account?

Yes, you need a GitHub account to generate a Personal Access Token. The token is required to access repositories (public or private).

Can I sync from private repositories?

Yes! When generating your GitHub Personal Access Token, make sure to select the repo scope for full access to private repositories.

How often does synchronization happen?

You can configure synchronization frequency per mapping: Manual only, Hourly, Twice daily, or Daily. You can also manually trigger sync at any time.

Will the plugin delete my WordPress content if I uninstall it?

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.

Can I sync multiple files from the same repository?

Yes! You can create multiple mappings, each pointing to different files in the same repository or different repositories.

How do I debug synchronization issues?

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.

", - "changelog": "

1.2.0 - 2026-08-07

Added

  • Title from H1: synced post title taken from the first Markdown H1 (and stripped from the body)
  • Internal link translation: repo-relative links rewritten to the matching WordPress permalink
  • Image sideloading: repository images imported into the Media Library and referenced by attachment URL

Security

  • Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)
  • GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)
  • CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d

1.1.1 - 2026-06-08

Security

  • CommonMark raw HTML passthrough disabled; token option no longer autoloaded

1.0.0 - 2026-01-26

Initial Release

  • Core synchronization, CommonMark conversion, encrypted token storage, mapping system, multi-repo support, debug tools
" + "changelog": "" }, "banners": { "low": "", diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index 2a76aeb..385b431 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -200,6 +200,7 @@ return array( '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\\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\\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', @@ -210,6 +211,8 @@ return array( '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\\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\\Table\\Table' => $vendorDir . '/league/commonmark/src/Extension/Table/Table.php', 'League\\CommonMark\\Extension\\Table\\TableCell' => $vendorDir . '/league/commonmark/src/Extension/Table/TableCell.php', diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 461c369..bdcd0e5 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -258,6 +258,7 @@ class ComposerStaticInitc097ca862608a882f291ae6b935e271c '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\\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\\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', @@ -268,6 +269,8 @@ class ComposerStaticInitc097ca862608a882f291ae6b935e271c '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\\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\\Table\\Table' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/Table.php', 'League\\CommonMark\\Extension\\Table\\TableCell' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Table/TableCell.php', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index c72c767..6194647 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -80,17 +80,17 @@ }, { "name": "league/commonmark", - "version": "2.9.0", - "version_normalized": "2.9.0.0", + "version": "2.10.0", + "version_normalized": "2.10.0.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -124,11 +124,11 @@ "suggest": { "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", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "installation-source": "dist", diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index ad44b8f..dfced0b 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -20,9 +20,9 @@ 'dev_requirement' => false, ), 'league/commonmark' => array( - 'pretty_version' => '2.9.0', - 'version' => '2.9.0.0', - 'reference' => '5703d83ba3da3b2e356a5fedc848ed6d8ffb6529', + 'pretty_version' => '2.10.0', + 'version' => '2.10.0.0', + 'reference' => 'd2d1aa8b35e072966c89bc0c66cf926e56767dc4', 'type' => 'library', 'install_path' => __DIR__ . '/../league/commonmark', 'aliases' => array(), diff --git a/vendor/league/commonmark/CHANGELOG.md b/vendor/league/commonmark/CHANGELOG.md index 95073f8..815e8ec 100644 --- a/vendor/league/commonmark/CHANGELOG.md +++ b/vendor/league/commonmark/CHANGELOG.md @@ -6,6 +6,69 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi ## [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 `{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 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 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.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 diff --git a/vendor/league/commonmark/composer.json b/vendor/league/commonmark/composer.json index bdd2959..3d3834f 100644 --- a/vendor/league/commonmark/composer.json +++ b/vendor/league/commonmark/composer.json @@ -116,7 +116,7 @@ }, "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "config": { diff --git a/vendor/league/commonmark/src/Environment/Environment.php b/vendor/league/commonmark/src/Environment/Environment.php index 191497c..80aa364 100644 --- a/vendor/league/commonmark/src/Environment/Environment.php +++ b/vendor/league/commonmark/src/Environment/Environment.php @@ -400,11 +400,13 @@ final class Environment implements EnvironmentInterface, EnvironmentBuilderInter \assert($normalizer instanceof TextNormalizerInterface); $this->injectEnvironmentAndConfigurationIfNeeded($normalizer); - if ($this->config->get('slug_normalizer/unique') !== UniqueSlugNormalizerInterface::DISABLED && ! $normalizer instanceof UniqueSlugNormalizer) { - $normalizer = new UniqueSlugNormalizer($normalizer); + if ($this->config->get('slug_normalizer/unique') !== UniqueSlugNormalizerInterface::DISABLED && ! $normalizer instanceof UniqueSlugNormalizerInterface) { + /** @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) { $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()), 'max_length' => Expect::int()->min(0)->default(255), 'unique' => Expect::anyOf(UniqueSlugNormalizerInterface::DISABLED, UniqueSlugNormalizerInterface::PER_ENVIRONMENT, UniqueSlugNormalizerInterface::PER_DOCUMENT)->default(UniqueSlugNormalizerInterface::PER_DOCUMENT), + 'reserved' => Expect::listOf('string')->default([]), ]), ]); } diff --git a/vendor/league/commonmark/src/Extension/Attributes/Event/AttributesListener.php b/vendor/league/commonmark/src/Extension/Attributes/Event/AttributesListener.php index a96d299..26d7fb0 100644 --- a/vendor/league/commonmark/src/Extension/Attributes/Event/AttributesListener.php +++ b/vendor/league/commonmark/src/Extension/Attributes/Event/AttributesListener.php @@ -24,6 +24,11 @@ use League\CommonMark\Extension\CommonMark\Node\Block\ListItem; use League\CommonMark\Node\Inline\AbstractInline; use League\CommonMark\Node\Node; +/** + * @psalm-type PendingAttributes = array{node: Node, front: array, back: array, classFront: list, classBack: list, hasClass: bool, unfiltered: array} + * + * @phpstan-type PendingAttributes = array{node: Node, front: array, back: array, classFront: list, classBack: list, hasClass: bool, unfiltered: array} + */ final class AttributesListener { private const DIRECTION_PREFIX = 'prefix'; @@ -44,12 +49,24 @@ final class AttributesListener public function processDocument(DocumentParsedEvent $event): void { + // Targets already worked out for attribute blocks we walked past; see findTargetAndDirection() + /** @var \SplObjectStorage> $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 $pending + */ + $pending = []; + foreach ($event->getDocument()->iterator() as $node) { if (! ($node instanceof Attributes || $node instanceof AttributesInline)) { continue; } - [$target, $direction] = self::findTargetAndDirection($node); + [$target, $direction] = self::findTargetAndDirection($node, $resolved); if ($target instanceof Node) { $parent = $target->parent(); @@ -57,29 +74,239 @@ final class AttributesListener $target = $parent; } - if ($direction === self::DIRECTION_SUFFIX) { - $attributes = AttributesHelper::mergeAttributes($target, $node->getAttributes()); - } else { - $attributes = AttributesHelper::mergeAttributes($node->getAttributes(), $target); + $id = \spl_object_id($target); + if (! isset($pending[$id])) { + $pending[$id] = self::newPendingEntry($target); } - $target->data->set('attributes', AttributesHelper::filterAttributes($attributes, $this->allowList, $this->allowUnsafeLinks)); + $this->contribute($pending[$id], $node->getAttributes(), $direction === self::DIRECTION_SUFFIX); } $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 $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 $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 $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 + */ + 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> $resolved * * @return array */ - 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; $direction = null; $previous = $next = $node; + /** @var list $shared */ + $shared = []; while (true) { $previous = self::getPrevious($previous); $next = self::getNext($next); @@ -119,9 +346,27 @@ final class AttributesListener 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 $result */ + $result = [$target, $direction]; + foreach ($shared as $sharedNode) { + $resolved[$sharedNode] = $result; + } + + return $result; } /** diff --git a/vendor/league/commonmark/src/Extension/Attributes/Parser/AttributesBlockContinueParser.php b/vendor/league/commonmark/src/Extension/Attributes/Parser/AttributesBlockContinueParser.php index 6e0cdc6..060c304 100644 --- a/vendor/league/commonmark/src/Extension/Attributes/Parser/AttributesBlockContinueParser.php +++ b/vendor/league/commonmark/src/Extension/Attributes/Parser/AttributesBlockContinueParser.php @@ -30,6 +30,27 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser 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 + */ + private array $attributes = []; + + /** @var list */ + 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 $attributes The attributes identified by the block start parser * @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->container = $container; + + $this->absorb($attributes); } public function getBlock(): AbstractBlock @@ -57,10 +80,9 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser $cursor->advanceToNextNonSpaceOrTab(); if ($cursor->isAtEnd() && $attributes !== []) { // It does! Merge them into what we parsed previously - $this->block->setAttributes(AttributesHelper::mergeAttributes( - $this->block->getAttributes(), - $attributes - )); + $this->classFirst = $this->classFirst || $this->hasClass; + + $this->absorb($attributes); // Tell the core parser we've consumed everything return BlockContinue::at($cursor); @@ -75,8 +97,49 @@ final class AttributesBlockContinueParser extends AbstractBlockContinueParser return BlockContinue::none(); } + /** + * Fold one line's attributes into what we've accumulated, in time proportional to that line + * + * @param array $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 { + $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 // so we can make that determination here if (! $this->hasSubsequentLine) { diff --git a/vendor/league/commonmark/src/Extension/Attributes/Util/AttributesHelper.php b/vendor/league/commonmark/src/Extension/Attributes/Util/AttributesHelper.php index 53a8287..79600ed 100644 --- a/vendor/league/commonmark/src/Extension/Attributes/Util/AttributesHelper.php +++ b/vendor/league/commonmark/src/Extension/Attributes/Util/AttributesHelper.php @@ -24,7 +24,14 @@ use League\CommonMark\Util\RegexHelper; final class AttributesHelper { 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 @@ -46,7 +53,7 @@ final class AttributesHelper // 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. // 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) { $cursor->restoreState($state); @@ -59,7 +66,7 @@ final class AttributesHelper /** @var array $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] === '#') { $attributes['id'] = \substr($attribute, 1); @@ -86,12 +93,12 @@ final class AttributesHelper $value = \substr($value, 1, -1); } - if (\strtolower(\trim($name)) === 'class') { - foreach (\array_filter(\explode(' ', \trim($value))) as $class) { + if (\strtolower(\trim($name, self::WHITESPACE)) === 'class') { + foreach (\array_filter(\explode(' ', \trim($value, self::WHITESPACE))) as $class) { $attributes['class'][] = $class; } } else { - $attributes[\trim($name)] = \trim($value); + $attributes[\trim($name, self::WHITESPACE)] = \trim($value, self::WHITESPACE); } } @@ -119,11 +126,7 @@ final class AttributesHelper /** @var array $arg */ $arg = (array) $arg; if (isset($arg['class'])) { - if (\is_string($arg['class'])) { - $arg['class'] = \array_filter(\explode(' ', \trim($arg['class']))); - } - - foreach ($arg['class'] as $class) { + foreach (self::classList($arg['class']) as $class) { $attributes['class'][] = $class; } @@ -140,6 +143,22 @@ final class AttributesHelper return $attributes; } + /** + * Split a `class` attribute value into the individual classes it contributes to a merge + * + * @param mixed $class + * + * @return list + */ + 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 $attributes * @param list $allowList @@ -151,7 +170,15 @@ final class AttributesHelper $allowList = \array_fill_keys($allowList, true); 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 if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) { diff --git a/vendor/league/commonmark/src/Extension/CommonMark/Delimiter/Processor/EmphasisDelimiterProcessor.php b/vendor/league/commonmark/src/Extension/CommonMark/Delimiter/Processor/EmphasisDelimiterProcessor.php index 9a6be13..49fff52 100644 --- a/vendor/league/commonmark/src/Extension/CommonMark/Delimiter/Processor/EmphasisDelimiterProcessor.php +++ b/vendor/league/commonmark/src/Extension/CommonMark/Delimiter/Processor/EmphasisDelimiterProcessor.php @@ -113,7 +113,10 @@ final class EmphasisDelimiterProcessor implements CacheableDelimiterProcessorInt $this->char, $closer->canOpen() ? 'canOpen' : 'cannotOpen', $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), ); } } diff --git a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Block/FencedCodeStartParser.php b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Block/FencedCodeStartParser.php index be1b1dc..7f5996e 100644 --- a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Block/FencedCodeStartParser.php +++ b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Block/FencedCodeStartParser.php @@ -27,7 +27,7 @@ final class FencedCodeStartParser implements BlockStartParserInterface } $indent = $cursor->getIndent(); - $fence = $cursor->match('/^[ \t]*(?:`{3,}(?!.*`)|~{3,})/'); + $fence = $cursor->matchInPlace('/\G[ \t]*(?:`{3,}+(?!.*`)|~{3,})/'); if ($fence === null) { return BlockStart::none(); } diff --git a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/BacktickParser.php b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/BacktickParser.php index 3324fe3..92aaf77 100644 --- a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/BacktickParser.php +++ b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/BacktickParser.php @@ -56,13 +56,13 @@ final class BacktickParser implements InlineParserInterface if ($this->findMatchingTicks(\strlen($ticks), $cursor)) { $code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks)); - $c = \preg_replace('/\n/m', ' ', $code) ?? ''; + $c = \str_replace("\n", ' ', $code); if ( $c !== '' && $c[0] === ' ' && \substr($c, -1, 1) === ' ' && - \preg_match('/[^ ]/', $c) + \strspn($c, ' ') !== \strlen($c) ) { $c = \substr($c, 1, -1); } @@ -110,7 +110,7 @@ final class BacktickParser implements InlineParserInterface return false; } - while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) { + while ($ticks = $cursor->matchInPlace('/`{1,' . self::MAX_BACKTICKS . '}/')) { $numTicks = \strlen($ticks); // Did we find the closer? diff --git a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/CloseBracketParser.php b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/CloseBracketParser.php index f3b83fd..34fabfa 100644 --- a/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/CloseBracketParser.php +++ b/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/CloseBracketParser.php @@ -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. $start = $opener->getPosition(); $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 { $cursor->restoreState($savePos); diff --git a/vendor/league/commonmark/src/Extension/DefaultAttributes/ApplyDefaultAttributesProcessor.php b/vendor/league/commonmark/src/Extension/DefaultAttributes/ApplyDefaultAttributesProcessor.php index 6b519f8..fcc8758 100644 --- a/vendor/league/commonmark/src/Extension/DefaultAttributes/ApplyDefaultAttributesProcessor.php +++ b/vendor/league/commonmark/src/Extension/DefaultAttributes/ApplyDefaultAttributesProcessor.php @@ -15,17 +15,29 @@ namespace League\CommonMark\Extension\DefaultAttributes; use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Extension\Attributes\Util\AttributesHelper; +use League\CommonMark\Node\Node; use League\Config\ConfigurationAwareInterface; use League\Config\ConfigurationInterface; +use League\Config\Exception\InvalidConfigurationException; final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterface { private ConfigurationInterface $config; + /** + * Configured attributes with every callback already resolved to a `Closure`, keyed by node FQCN. + * + * @var array>|null + */ + private ?array $resolved = null; + public function onDocumentParsed(DocumentParsedEvent $event): void { - /** @var array> $map */ - $map = $this->config->get('default_attributes'); + if ($this->resolved === null) { + $this->resolved = $this->resolveConfiguredAttributes(); + } + + $map = $this->resolved; // Don't bother iterating if no default attributes are configured if (! $map) { @@ -40,7 +52,7 @@ final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterfa $newAttributes = []; foreach ($attributesToApply as $name => $value) { - if (\is_callable($value)) { + if ($value instanceof \Closure) { $value = $value($node); // Callables are allowed to return `null` indicating that no changes should be made if ($value !== null) { @@ -62,4 +74,78 @@ final class ApplyDefaultAttributesProcessor implements ConfigurationAwareInterfa { $this->config = $configuration; } + + /** + * @return array> + */ + private function resolveConfiguredAttributes(): array + { + /** @var array> $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); + } + }; + } } diff --git a/vendor/league/commonmark/src/Extension/DefaultAttributes/DefaultAttributesExtension.php b/vendor/league/commonmark/src/Extension/DefaultAttributes/DefaultAttributesExtension.php index 152c29a..bb381c4 100644 --- a/vendor/league/commonmark/src/Extension/DefaultAttributes/DefaultAttributesExtension.php +++ b/vendor/league/commonmark/src/Extension/DefaultAttributes/DefaultAttributesExtension.php @@ -21,15 +21,52 @@ use Nette\Schema\Expect; 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 { - $builder->addSchema('default_attributes', Expect::arrayOf( - Expect::arrayOf( - Expect::type('string|string[]|bool|callable'), // attribute value(s) - 'string' // attribute name - ), - 'string' // node FQCN - )->default([])); + $builder->addSchema('default_attributes', Expect::structure([ + 'attributes' => Expect::arrayOf( + Expect::arrayOf( + Expect::type('string|string[]|bool|callable'), // attribute value(s) + 'string' // attribute name + ), + '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 diff --git a/vendor/league/commonmark/src/Extension/DescriptionList/Parser/DescriptionStartParser.php b/vendor/league/commonmark/src/Extension/DescriptionList/Parser/DescriptionStartParser.php index b4e8c98..04762e7 100644 --- a/vendor/league/commonmark/src/Extension/DescriptionList/Parser/DescriptionStartParser.php +++ b/vendor/league/commonmark/src/Extension/DescriptionList/Parser/DescriptionStartParser.php @@ -29,7 +29,7 @@ final class DescriptionStartParser implements BlockStartParserInterface } $cursor->advanceToNextNonSpaceOrTab(); - if ($cursor->match('/^:[ \t]+/') === null) { + if ($cursor->matchInPlace('/\G:[ \t]+/') === null) { return BlockStart::none(); } diff --git a/vendor/league/commonmark/src/Extension/FrontMatter/FrontMatterParser.php b/vendor/league/commonmark/src/Extension/FrontMatter/FrontMatterParser.php index 69c41d1..12a0c9a 100644 --- a/vendor/league/commonmark/src/Extension/FrontMatter/FrontMatterParser.php +++ b/vendor/league/commonmark/src/Extension/FrontMatter/FrontMatterParser.php @@ -23,7 +23,7 @@ final class FrontMatterParser implements FrontMatterParserInterface /** @psalm-readonly */ 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) { @@ -38,7 +38,7 @@ final class FrontMatterParser implements FrontMatterParserInterface $cursor = new Cursor($markdownContent); // Locate the front matter - $frontMatter = $cursor->match(self::REGEX_FRONT_MATTER); + $frontMatter = $cursor->matchInPlace(self::REGEX_FRONT_MATTER); if ($frontMatter === null) { return new MarkdownInputWithFrontMatter($markdownContent); } @@ -53,7 +53,7 @@ final class FrontMatterParser implements FrontMatterParserInterface $data = $this->frontMatterParser->parse($frontMatter); // 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 // Don't forget to add 1 because we stripped one out when trimming the trailing delims diff --git a/vendor/league/commonmark/src/Extension/HeadingPermalink/HeadingPermalinkRenderer.php b/vendor/league/commonmark/src/Extension/HeadingPermalink/HeadingPermalinkRenderer.php index 59a86a1..ba2d6f8 100644 --- a/vendor/league/commonmark/src/Extension/HeadingPermalink/HeadingPermalinkRenderer.php +++ b/vendor/league/commonmark/src/Extension/HeadingPermalink/HeadingPermalinkRenderer.php @@ -73,6 +73,9 @@ final class HeadingPermalinkRenderer implements NodeRendererInterface, XmlNodeRe $hidden = $this->config->get('heading_permalink/aria_hidden'); if ($hidden) { $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')); diff --git a/vendor/league/commonmark/src/Extension/Highlight/MarkDelimiterProcessor.php b/vendor/league/commonmark/src/Extension/Highlight/MarkDelimiterProcessor.php index bb272ce..6773172 100644 --- a/vendor/league/commonmark/src/Extension/Highlight/MarkDelimiterProcessor.php +++ b/vendor/league/commonmark/src/Extension/Highlight/MarkDelimiterProcessor.php @@ -64,6 +64,10 @@ class MarkDelimiterProcessor implements CacheableDelimiterProcessorInterface 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); } } diff --git a/vendor/league/commonmark/src/Extension/Strikethrough/StrikethroughDelimiterProcessor.php b/vendor/league/commonmark/src/Extension/Strikethrough/StrikethroughDelimiterProcessor.php index a6c8d38..020ae86 100644 --- a/vendor/league/commonmark/src/Extension/Strikethrough/StrikethroughDelimiterProcessor.php +++ b/vendor/league/commonmark/src/Extension/Strikethrough/StrikethroughDelimiterProcessor.php @@ -64,6 +64,10 @@ final class StrikethroughDelimiterProcessor implements CacheableDelimiterProcess 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); } } diff --git a/vendor/league/commonmark/src/Extension/Table/TableStartParser.php b/vendor/league/commonmark/src/Extension/Table/TableStartParser.php index 7411951..1baf29c 100644 --- a/vendor/league/commonmark/src/Extension/Table/TableStartParser.php +++ b/vendor/league/commonmark/src/Extension/Table/TableStartParser.php @@ -107,7 +107,7 @@ final class TableStartParser implements BlockStartParserInterface $cursor->advanceBy(1); } - if ($cursor->match('/^-+/') === null) { + if ($cursor->matchInPlace('/\G-+/') === null) { // Need at least one dash return []; } diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php b/vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php new file mode 100644 index 0000000..4566d52 --- /dev/null +++ b/vendor/league/commonmark/src/Extension/TableOfContents/Node/TableOfContentsReference.php @@ -0,0 +1,47 @@ + + * + * 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; + } +} diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsBuilder.php b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsBuilder.php index 7fe2b09..d77e0e9 100644 --- a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsBuilder.php +++ b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsBuilder.php @@ -14,10 +14,13 @@ declare(strict_types=1); namespace League\CommonMark\Extension\TableOfContents; 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\ListItem; use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink; use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsReference; use League\CommonMark\Node\Block\Document; use League\CommonMark\Node\NodeIterator; use League\Config\ConfigurationAwareInterface; @@ -89,16 +92,66 @@ final class TableOfContentsBuilder implements ConfigurationAwareInterface 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) { // Add the block once we find a placeholder if (! $node instanceof TableOfContentsPlaceholder) { 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 { $this->config = $configuration; diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsExtension.php b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsExtension.php index 9c8223b..5fab819 100644 --- a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsExtension.php +++ b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsExtension.php @@ -15,11 +15,13 @@ namespace League\CommonMark\Extension\TableOfContents; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Event\DocumentPreRenderEvent; use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock; use League\CommonMark\Extension\CommonMark\Renderer\Block\ListBlockRenderer; use League\CommonMark\Extension\ConfigurableExtensionInterface; use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsReference; use League\Config\ConfigurationBuilderInterface; use Nette\Schema\Expect; @@ -35,19 +37,26 @@ final class TableOfContentsExtension implements ConfigurableExtensionInterface 'max_heading_level' => Expect::int()->min(1)->max(6)->default(6), 'html_class' => Expect::string()->default('table-of-contents'), '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 { + $builder = new TableOfContentsBuilder(); + $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 if ($environment->getConfiguration()->get('table_of_contents/position') === TableOfContentsBuilder::POSITION_PLACEHOLDER) { $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 $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']); } } } diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php index b27ddee..3271e51 100644 --- a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php +++ b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php @@ -58,7 +58,7 @@ final class TableOfContentsPlaceholderParser extends AbstractBlockContinueParser } // 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(); } diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php new file mode 100644 index 0000000..34b102c --- /dev/null +++ b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsReferenceRenderer.php @@ -0,0 +1,44 @@ + + * + * 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; + } +} diff --git a/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php new file mode 100644 index 0000000..fb86aaa --- /dev/null +++ b/vendor/league/commonmark/src/Extension/TableOfContents/TableOfContentsRenderCache.php @@ -0,0 +1,34 @@ + + * + * 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; + } +} diff --git a/vendor/league/commonmark/src/Node/Inline/AdjacentTextMerger.php b/vendor/league/commonmark/src/Node/Inline/AdjacentTextMerger.php index 43922d4..7d82e63 100644 --- a/vendor/league/commonmark/src/Node/Inline/AdjacentTextMerger.php +++ b/vendor/league/commonmark/src/Node/Inline/AdjacentTextMerger.php @@ -89,17 +89,13 @@ final class AdjacentTextMerger return; } - $s = $first->getLiteral(); - $node = $first->next(); $stop = $last->next(); while ($node !== $stop && $node instanceof Text) { - $s .= $node->getLiteral(); + $first->append($node->getLiteral()); $unlink = $node; $node = $node->next(); $unlink->detach(); } - - $first->setLiteral($s); } } diff --git a/vendor/league/commonmark/src/Node/Node.php b/vendor/league/commonmark/src/Node/Node.php index 484b39c..1481150 100644 --- a/vendor/league/commonmark/src/Node/Node.php +++ b/vendor/league/commonmark/src/Node/Node.php @@ -21,7 +21,7 @@ use League\CommonMark\Exception\InvalidArgumentException; abstract class Node { - /** @psalm-readonly */ + /** @psalm-readonly-allow-private-mutation */ public Data $data; /** @psalm-readonly-allow-private-mutation */ @@ -243,14 +243,18 @@ abstract class Node $this->parent = null; $this->previous = null; $this->next = null; - // But save a copy of the children since we'll need that in a moment - $children = $this->children(); - $this->detachChildren(); + // But save a copy of the children since we'll need that in a moment. + // Those children still belong to the node being cloned, so only this copy's own links may be dropped. + $children = $this->children(); + $this->firstChild = $this->lastChild = null; // The original children get cloned and re-added foreach ($children as $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 diff --git a/vendor/league/commonmark/src/Normalizer/UniqueSlugNormalizer.php b/vendor/league/commonmark/src/Normalizer/UniqueSlugNormalizer.php index 6374568..fe3635b 100644 --- a/vendor/league/commonmark/src/Normalizer/UniqueSlugNormalizer.php +++ b/vendor/league/commonmark/src/Normalizer/UniqueSlugNormalizer.php @@ -17,21 +17,40 @@ namespace League\CommonMark\Normalizer; final class UniqueSlugNormalizer implements UniqueSlugNormalizerInterface { 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 + */ + private array $reserved = []; + /** * Every slug we've handed out, mapped to the next numeric suffix to try for it * * @var array */ - private array $alreadyUsed = []; + private array $alreadyUsed; - public function __construct(TextNormalizerInterface $innerNormalizer) + /** + * @param iterable $reservedSlugs + */ + public function __construct(TextNormalizerInterface $innerNormalizer, iterable $reservedSlugs = []) { $this->innerNormalizer = $innerNormalizer; + + foreach ($reservedSlugs as $slug) { + $this->reserved[$slug] = 1; + } + + $this->alreadyUsed = $this->reserved; } public function clearHistory(): void { - $this->alreadyUsed = []; + $this->alreadyUsed = $this->reserved; } /** diff --git a/vendor/league/commonmark/src/Parser/Cursor.php b/vendor/league/commonmark/src/Parser/Cursor.php index ca5e414..22967cf 100644 --- a/vendor/league/commonmark/src/Parser/Cursor.php +++ b/vendor/league/commonmark/src/Parser/Cursor.php @@ -170,6 +170,10 @@ class Cursor * Return the single multibyte character at $index, caching the sliced string so * repeated reads of the same position (common during delimiter scanning) don't * 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 { @@ -179,7 +183,13 @@ class Cursor $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. $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++) { $c = $this->line[$byteOffset]; @@ -450,13 +469,22 @@ class Cursor return 0; } - $matches = []; - \preg_match('/^ *(?:\n *)?/', $this->getRemainder(), $matches, \PREG_OFFSET_CAPTURE); + // A partially-consumed tab leaves the cursor sitting on the tab itself, which the check + // 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 - // [0][1] contains the index of that match - \assert(isset($matches[0])); - $increment = $matches[0][1] + \strlen($matches[0][0]); + $increment = \strspn($this->line, ' ', $byteOffset); + if (($this->line[$byteOffset + $increment] ?? '') === "\n") { + $increment++; + $increment += \strspn($this->line, ' ', $byteOffset + $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 * @@ -518,23 +551,70 @@ class Cursor */ public function match(string $regex): ?string { - // When a tab has been partially consumed the remainder is reconstructed with the - // 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 - // the exact column arithmetic. - if ($this->partiallyConsumedTab) { - return $this->matchViaRemainder($regex); + $subject = $this->getRemainder(); + + if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) { + return null; } - // Match against the persistent line at the current byte offset instead of allocating a - // fresh copy of the remainder on every call. A leading "^" is rewritten to "\G" so the - // pattern still anchors to the cursor - a bare "^" only matches at the true start of the - // subject when a non-zero offset is supplied. Patterns that intentionally scan ahead - // (e.g. the backtick closer search) carry no leading "^" and are left untouched. This - // keeps repeated match() calls - such as that backtick scan - linear rather than O(n^2), - // since each call no longer copies the entire remaining line. - if ($regex[1] === '^') { - $regex = $regex[0] . '\\G' . \substr($regex, 2); + // $matches[0][0] contains the matched text; $matches[0][1] is its byte offset in the subject. + 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 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; @@ -560,48 +640,6 @@ class Cursor 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. * diff --git a/vendor/league/commonmark/src/Util/LinkParserHelper.php b/vendor/league/commonmark/src/Util/LinkParserHelper.php index 3e76c28..058db7a 100644 --- a/vendor/league/commonmark/src/Util/LinkParserHelper.php +++ b/vendor/league/commonmark/src/Util/LinkParserHelper.php @@ -46,7 +46,7 @@ final class LinkParserHelper public static function parseLinkLabel(Cursor $cursor): int { - $match = $cursor->match('/^\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/'); + $match = $cursor->matchInPlace('/\G\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/'); if ($match === null) { return 0; } @@ -62,7 +62,7 @@ final class LinkParserHelper 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 { - 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 return RegexHelper::unescape(\substr($title, 1, -1)); } @@ -84,7 +84,7 @@ final class LinkParserHelper { $endDelimiter = \preg_quote($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; } @@ -93,12 +93,19 @@ final class LinkParserHelper 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; - $len = \strlen($remainder); + $len = \strlen($line) - $start; for ($i = 0; $i < $len; $i++) { - $c = $remainder[$i]; - if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) { + $c = $line[$start + $i]; + if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($line[$start + $i + 1])) { $i++; } elseif ($c === '(') { $openParens++; @@ -125,7 +132,7 @@ final class LinkParserHelper return null; } - $destination = \substr($remainder, 0, $i); + $destination = \substr($line, $start, $i); $cursor->advanceBy(\mb_strlen($destination, 'UTF-8')); return $destination; @@ -149,7 +156,7 @@ final class LinkParserHelper 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; // Chop off surrounding <..>: diff --git a/vendor/league/commonmark/src/Util/RegexHelper.php b/vendor/league/commonmark/src/Util/RegexHelper.php index 641d757..9e156f2 100644 --- a/vendor/league/commonmark/src/Util/RegexHelper.php +++ b/vendor/league/commonmark/src/Util/RegexHelper.php @@ -61,9 +61,15 @@ final class RegexHelper self::PARTIAL_PROCESSINGINSTRUCTION . '|' . self::PARTIAL_DECLARATION . '|' . self::PARTIAL_CDATA . ')'; public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . 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])*+\))'; + /** @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_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_UNICODE_WHITESPACE_CHAR = '/^\pZ|\s/u'; 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