This commit is contained in:
Javier Casares 2026-08-08 08:53:08 +00:00
commit 7d3fc1969d
52 changed files with 1776 additions and 295 deletions

View file

@ -1,5 +1,65 @@
== Changelog ==
= 1.2.0 =
_Release date: 2026-08-07_
**Highlights**
* New: synced post title is taken from the first H1 in the Markdown (and stripped from the body)
* New: repo-relative links are rewritten to the matching WordPress permalink
* New: repository images are sideloaded into the Media Library and referenced by attachment URL
* All deferred findings from the 1.1.1 pre-deploy audit resolved
* GitHub token encryption hardened with HKDF-SHA256 key derivation (with transparent migration of existing tokens)
* CSRF nonce added to the Discover Files "Refresh" action
* Minimum WordPress raised to 6.8; verified compatible with WordPress 7.1; PHP minimum declarations made consistent at 8.0 across all files
**Added**
* **Title from H1:** the synced post title is now taken from the first `# H1` heading in the Markdown (inline formatting stripped to plain text), with the H1 removed from the body to avoid a duplicate heading. Falls back to the filename-derived title when no H1 is present
* **Internal link translation:** repo-relative Markdown links (e.g. `./api.md`, `../README.md`) are rewritten to the permalink of the matching mapped WordPress content. External, `mailto:`, and anchor links are left untouched; links with no matching mapping keep their original URL
* **Repository image sideloading:** images referenced in the Markdown are downloaded from the repository, added to the Media Library, and their references replaced with the attachment URL. Already-imported images are reused on subsequent syncs (tracked per post); removed images are kept in the Media Library (non-destructive)
**Security**
* Patched CVE-2026-71478 (and advisory GHSA-2q4p-g7hv-5rgv) in league/commonmark — an unsafe-link filter bypass that could defeat `allow_unsafe_links: false`. Updated league/commonmark 2.8.2 → 2.9.0
* GitHub token encryption now derives its AES-256 key with HKDF-SHA256 from `wp_salt('auth')` instead of using the salt directly; existing tokens are migrated transparently to the new `v2:` format on first decrypt (covers the cron path too)
* Discover Files "Refresh from GitHub" action is now nonce-protected — previously a crafted link could force an unrequested GitHub API call (CSRF)
* GitHub API request paths are now `rawurlencode()`d (defensive hardening)
* Sideloaded images are validated by extension (jpg, jpeg, png, gif, webp) and size (< 10 MB) before storage; SVG is intentionally excluded
**Fixed**
* "Using cached data / fetching fresh data" indicator on Discover Files now reflects reality — it always showed "cached" because the cache was tested after being populated
* `target_post_type` is now validated against registered public post types on save, falling back to `page`
* Debug action dispatch refactored into a single `switch`, each case retaining its `check_admin_referer()` check, to reduce the chance of a missing nonce check
**Changed**
* Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile)
* Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites
**Compatibility**
* WordPress: 6.8 - 7.1
* PHP: 8.0 - 8.5
* MariaDB: 11.4 or newer
**Developer**
* Added `bin/preflight.sh` — automated pre-deploy gate (PHPCS, PHPStan 9, PHPCompatibility, PHPUnit, `composer audit`, candidate-ZIP inspection); PASS/FAIL report per section, never invokes `deploy.sh`
* Added `.claude/settings.json` deny rules (`deploy.sh`, `git push/tag/merge`) enforcing the AGENTS.md operating boundaries mechanically
* `composer.json` `require-dev` completed and pinned: added `dealerdirect/phpcodesniffer-composer-installer` + `phpcsstandards/phpcsutils`; pinned `squizlabs/php_codesniffer` and `johnbillion/wp-compat`
* Patched two high-severity CVEs in dev tooling (not shipped — `deploy.sh` uses `--no-dev`): `squizlabs/php_codesniffer` 3.13.5 → 3.13.6 (CVE-2026-67434), `wp-coding-standards/wpcs` 3.3.0 → 3.4.1 (CVE-2026-45293). `composer audit` fully clean
**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
* PHPUnit: plugin header tests pass
* Manual testing: WordPress 7.1
= 1.1.1 =
_Release date: 2026-06-08_

View file

@ -1,10 +1,10 @@
=== Documentation Markdown (by ROBOTSTXT) ===
Contributors: robotstxt
Tags: github, documentation, markdown, sync, automation
Requires at least: 6.7
Requires at least: 6.8
Tested up to: 7.1
Requires PHP: 8.0
Stable tag: 1.1.1
Stable tag: 1.2.0
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -44,8 +44,8 @@ Synchronize Markdown documentation from GitHub repositories to WordPress pages a
= Requirements =
* PHP 8.2 or higher
* WordPress 6.9 or higher
* PHP 8.0 or higher
* WordPress 6.8 or higher
* GitHub Personal Access Token (free, for accessing repositories)
* Composer (for production build with dependencies)
@ -63,7 +63,7 @@ Synchronize Markdown documentation from GitHub repositories to WordPress pages a
* Clean, well-documented code
* Follows WordPress Coding Standards (WPCS)
* Modern PHP 8.2+ features
* Modern PHP 8.0+ features
* Extensive PHPDoc documentation
* Procedural approach (KISS principles)
* Extensible with WordPress hooks and filters
@ -178,13 +178,46 @@ Then go to Documentation → Settings, and you'll see a "Debug Tools" section at
== Compatibility ==
* WordPress: 6.7 - 7.1
* WordPress: 6.8 - 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).
= 1.2.0 - 2026-08-07 =
**Added**
* **Title from H1:** the synced post title is now taken from the first `# H1` in the Markdown (inline formatting stripped), with the H1 removed from the body to avoid a duplicate heading. Falls back to the filename-derived title when no H1 is present
* **Internal link translation:** repo-relative Markdown links (e.g. `./api.md`, `../README.md`) are rewritten to the permalink of the matching mapped WordPress content. External, `mailto:`, and anchor links are left untouched; links with no matching mapping keep their original URL
* **Repository image sideloading:** images referenced in the Markdown are downloaded from the repository, added to the Media Library, and their references replaced with the attachment URL. Already-imported images are reused on subsequent syncs (tracked per post); removed images are kept in the Media Library (non-destructive)
**Security**
* Patched CVE-2026-71478 (and advisory GHSA-2q4p-g7hv-5rgv) in league/commonmark — an unsafe-link filter bypass that could defeat `allow_unsafe_links: false`. Updated league/commonmark 2.8.2 → 2.9.0
* GitHub token encryption now derives its AES-256 key with HKDF-SHA256 from `wp_salt('auth')` instead of using the salt directly; existing tokens are migrated transparently to the new `v2:` format on first decrypt (covers the cron path too)
* Discover Files "Refresh from GitHub" action is now nonce-protected — previously a crafted link could force an unrequested GitHub API call (CSRF)
* GitHub API request paths are now `rawurlencode()`d (defensive hardening)
* Sideloaded images are validated by extension (jpg, jpeg, png, gif, webp) and size (< 10 MB) before storage; SVG is intentionally excluded
**Fixed**
* "Using cached data / fetching fresh data" indicator on Discover Files now reflects reality — it always showed "cached" because the cache was tested after being populated
* `target_post_type` is now validated against registered public post types on save, falling back to `page` (prevents saving an unregistered or internal type)
* Debug action dispatch refactored into a single `switch`, each case retaining its `check_admin_referer()` check, to reduce the chance of a missing nonce check
**Changed**
* Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile)
* Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites
**Compatibility**
* Minimum WordPress raised to 6.8 (latest stable + two previous majors)
* Verified compatible with WordPress 7.1
* Declared PHP minimum made consistent across `readme.txt`, `composer.json`, `phpstan.neon`, and the PHPCompatibility scan range — real minimum remains 8.0
= 1.1.1 - 2026-06-08 =
**Security**
@ -274,7 +307,7 @@ For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTST
**Developer Features:**
* Procedural PHP following KISS principles
* PHP 8.2+ modern features
* PHP 8.0+ modern features
* Complete PHPDoc documentation
* WordPress hooks and filters
* Extensible architecture

View file

@ -0,0 +1,422 @@
<?php
/**
* Synced-content processing: H1 title extraction, internal-link translation,
* and repository image sideloading.
*
* @package RobotsTxt\DocumentationMarkdown
* @since 1.2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Reduce inline Markdown formatting in a single line to plain text.
*
* Used to derive a clean post title from an H1 that may contain links,
* emphasis, inline code, or images.
*
* @since 1.2.0
*
* @param string $text Inline Markdown text (single line).
* @return string Sanitized plain text.
*/
function robotstxt_docmd_plain_text_from_inline_markdown( string $text ): string {
$patterns = array(
'/!\[([^\]]*)\]\([^)]*\)/', // Images -> alt text.
'/\[([^\]]*)\]\([^)]*\)/', // Links -> label text.
'/`([^`]*)`/', // Inline code -> contents.
'/(\*\*|__|~~|\*|_)(.+?)\1/', // Bold/italic/strikethrough -> contents.
);
$replacements = array( '$1', '$1', '$1', '$2' );
$result = preg_replace( $patterns, $replacements, $text );
if ( ! is_string( $result ) ) {
$result = $text;
}
return sanitize_text_field( trim( $result ) );
}
/**
* Extract the post title from the first H1 in the Markdown, and remove that
* H1 line from the content so it does not duplicate the theme-rendered title.
*
* Falls back to $fallback_title when no H1 is present or the extracted text
* is empty after sanitization.
*
* @since 1.2.0
*
* @param string $content Raw Markdown.
* @param string $fallback_title Fallback title if no H1 is found.
* @return array{title: string, content: string} {
* Extracted title (plain text) and the Markdown with the first H1 removed.
*
* @type string $title Post title.
* @type string $content Markdown with the first H1 line removed.
* }
*/
function robotstxt_docmd_extract_h1_title( string $content, string $fallback_title ): array {
if ( preg_match( '/^#[ \t]+(.+)$/m', $content, $matches ) ) {
$title = robotstxt_docmd_plain_text_from_inline_markdown( trim( $matches[1] ) );
if ( '' === $title ) {
$title = $fallback_title;
}
$replaced = preg_replace( '/^#[ \t]+.*$/m', '', $content, 1 );
if ( is_string( $replaced ) ) {
$content = $replaced;
}
} else {
$title = $fallback_title;
}
return array(
'title' => $title,
'content' => $content,
);
}
/**
* Resolve a (possibly relative) Markdown link target to a canonical repository
* path, relative to the file currently being synced.
*
* Handles "./file.md", "../file.md", "sub/file.md", and absolute "/file.md"
* forms. Returns null for empty targets.
*
* @since 1.2.0
*
* @param string $current_file_path Repository path of the file being synced.
* @param string $href Raw link target.
* @return string|null Canonical repository path, or null if it cannot be resolved.
*/
function robotstxt_docmd_resolve_repo_path( string $current_file_path, string $href ): ?string {
// Strip any fragment or query string before resolving.
$path = preg_replace( '/[?#].*$/', '', $href );
if ( ! is_string( $path ) || '' === $path ) {
return null;
}
if ( 0 === strpos( $path, '/' ) ) {
// Absolute repository path.
$base_segments = array();
$rel_segments = explode( '/', ltrim( $path, '/' ) );
} else {
// Relative to the current file's directory.
$dir = dirname( $current_file_path );
$base_segments = '.' === $dir ? array() : explode( '/', $dir );
$rel_segments = explode( '/', $path );
}
$result = $base_segments;
foreach ( $rel_segments as $segment ) {
if ( '' === $segment || '.' === $segment ) {
continue;
}
if ( '..' === $segment ) {
array_pop( $result );
continue;
}
$result[] = $segment;
}
$normalized = implode( '/', $result );
if ( '' === $normalized ) {
return null;
}
return $normalized;
}
/**
* Build a lookup of canonical repository file paths to WordPress permalinks,
* scoped to the given repository and branch. Only mappings with an existing
* target post are included.
*
* @since 1.2.0
*
* @param string $owner Repository owner.
* @param string $repo Repository name.
* @param string $branch Branch name.
* @return array<string, string> Map of repository path => permalink.
*/
function robotstxt_docmd_build_link_map( string $owner, string $repo, string $branch ): array {
$mappings = robotstxt_docmd_get_all_mappings();
$map = array();
foreach ( $mappings as $mapping ) {
if ( $mapping['repo_owner'] !== $owner || $mapping['repo_name'] !== $repo || $mapping['branch'] !== $branch ) {
continue;
}
if ( empty( $mapping['target_post_id'] ) ) {
continue;
}
$permalink = get_permalink( (int) $mapping['target_post_id'] );
if ( is_string( $permalink ) && '' !== $permalink ) {
$map[ $mapping['file_path'] ] = $permalink;
}
}
return $map;
}
/**
* Get the imported-image asset map for a synced post.
*
* @since 1.2.0
*
* @param int $post_id Target post ID.
* @return array<string, int> Map of repository image path => attachment ID.
*/
function robotstxt_docmd_get_asset_map( int $post_id ): array {
$raw = get_post_meta( $post_id, '_robotstxt_docmd_assets', true );
if ( ! is_array( $raw ) ) {
return array();
}
$map = array();
foreach ( $raw as $path => $attachment_id ) {
if ( is_string( $path ) && ( is_int( $attachment_id ) || ( is_string( $attachment_id ) && ctype_digit( $attachment_id ) ) ) ) {
$map[ $path ] = (int) $attachment_id;
}
}
return $map;
}
/**
* Persist the imported-image asset map for a synced post.
*
* @since 1.2.0
*
* @param int $post_id Target post ID.
* @param array $map Map of repository image path => attachment ID.
* @phpstan-param array<string, int> $map
* @return void
*/
function robotstxt_docmd_save_asset_map( int $post_id, array $map ): void {
update_post_meta( $post_id, '_robotstxt_docmd_assets', $map );
}
/**
* Return the list of image extensions allowed for sideloading.
*
* SVG is intentionally excluded to avoid stored XSS via embedded scripts.
*
* @since 1.2.0
*
* @return list<string>
*/
function robotstxt_docmd_allowed_image_extensions(): array {
return array( 'jpg', 'jpeg', 'png', 'gif', 'webp' );
}
/**
* Download an image from the repository and import it into the Media Library.
*
* @since 1.2.0
*
* @param string $repo_path Canonical repository path of the image.
* @param int $target_post_id Post to attach the image to.
* @param string $owner Repository owner.
* @param string $repo Repository name.
* @param string $branch Branch name.
* @param string $token Encrypted GitHub token.
* @return int|WP_Error Attachment ID on success, or WP_Error.
*/
function robotstxt_docmd_sideload_repo_image( string $repo_path, int $target_post_id, string $owner, string $repo, string $branch, string $token ): int|WP_Error {
$ext = strtolower( (string) pathinfo( $repo_path, PATHINFO_EXTENSION ) );
$allowed = robotstxt_docmd_allowed_image_extensions();
if ( ! in_array( $ext, $allowed, true ) ) {
return new WP_Error(
'unsupported_image_type',
sprintf(
/* translators: %s: file extension */
__( 'Unsupported image type .%s. Allowed: jpg, jpeg, png, gif, webp.', 'robotstxt-documentation-markdown' ),
$ext
)
);
}
// Fetch image bytes from GitHub.
$bytes = robotstxt_docmd_get_file_content( $owner, $repo, $repo_path, $branch, $token );
if ( is_wp_error( $bytes ) ) {
return $bytes;
}
// Size guard (10 MB).
if ( strlen( $bytes ) > 10 * 1024 * 1024 ) {
return new WP_Error( 'image_too_large', __( 'Repository image exceeds the 10 MB import limit.', 'robotstxt-documentation-markdown' ) );
}
if ( ! function_exists( 'media_handle_sideload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
$filename = sanitize_file_name( basename( $repo_path ) );
$tmp_name = wp_tempnam( $filename );
if ( '' === $tmp_name ) {
return new WP_Error( 'temp_failed', __( 'Could not create a temporary file for image import.', 'robotstxt-documentation-markdown' ) );
}
global $wp_filesystem;
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
wp_delete_file( $tmp_name );
return new WP_Error( 'fs_failed', __( 'Could not initialize the WordPress filesystem.', 'robotstxt-documentation-markdown' ) );
}
if ( ! $wp_filesystem->put_contents( $tmp_name, $bytes, FS_CHMOD_FILE ) ) {
wp_delete_file( $tmp_name );
return new WP_Error( 'write_failed', __( 'Could not write image to the temporary file.', 'robotstxt-documentation-markdown' ) );
}
$attach_id = media_handle_sideload(
array(
'name' => $filename,
'tmp_name' => $tmp_name,
),
$target_post_id
);
if ( is_wp_error( $attach_id ) ) {
wp_delete_file( $tmp_name );
return $attach_id;
}
return (int) $attach_id;
}
/**
* Post-process synced HTML: rewrite internal links to mapped permalinks and
* sideload repository images into the Media Library.
*
* Internal-link and image references are parsed with DOMDocument (no regex on
* HTML). Images already present in $asset_map are reused. Image import errors
* are non-fatal: the original src is kept and the sync continues.
*
* @since 1.2.0
*
* @param string $html Converted HTML content.
* @param array $mapping Mapping being synced.
* @param int $target_post_id Target post ID (for image attachment).
* @param string $token Encrypted GitHub token (for image fetch).
* @param array $asset_map Existing imported-image map (path => attach ID).
* @phpstan-param MappingData $mapping
* @phpstan-param array<string, int> $asset_map
* @return array{html: string, assets: array<string, int>} Processed HTML and the updated asset map.
*/
function robotstxt_docmd_process_synced_html( string $html, array $mapping, int $target_post_id, string $token, array $asset_map ): array {
$dom = new DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML(
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $html . '</body></html>'
);
libxml_clear_errors();
$link_map = robotstxt_docmd_build_link_map( $mapping['repo_owner'], $mapping['repo_name'], $mapping['branch'] );
// Rewrite internal anchor hrefs.
foreach ( $dom->getElementsByTagName( 'a' ) as $link ) {
$href = $link->getAttribute( 'href' );
if ( '' === $href ) {
continue;
}
// Skip absolute, protocol-relative, mailto/tel, and anchor-only links.
if ( preg_match( '~^(https?:|mailto:|tel:|//|#)~', $href ) ) {
continue;
}
$resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $href );
if ( null === $resolved ) {
continue;
}
if ( isset( $link_map[ $resolved ] ) ) {
$link->setAttribute( 'href', $link_map[ $resolved ] );
}
}
// Sideload or reuse repository images.
foreach ( $dom->getElementsByTagName( 'img' ) as $image ) {
$src = $image->getAttribute( 'src' );
if ( '' === $src ) {
continue;
}
// Skip absolute, protocol-relative, and data URIs.
if ( preg_match( '~^(https?:|//|data:)~', $src ) ) {
continue;
}
$resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $src );
if ( null === $resolved ) {
continue;
}
// Reuse an already-imported attachment.
if ( isset( $asset_map[ $resolved ] ) ) {
$url = wp_get_attachment_url( (int) $asset_map[ $resolved ] );
if ( is_string( $url ) && '' !== $url ) {
$image->setAttribute( 'src', $url );
}
continue;
}
// Import the image from the repository.
$attach_id = robotstxt_docmd_sideload_repo_image(
$resolved,
$target_post_id,
$mapping['repo_owner'],
$mapping['repo_name'],
$mapping['branch'],
$token
);
if ( is_wp_error( $attach_id ) ) {
// Keep the original src; do not block the sync.
continue;
}
$asset_map[ $resolved ] = $attach_id;
$url = wp_get_attachment_url( $attach_id );
if ( is_string( $url ) && '' !== $url ) {
$image->setAttribute( 'src', $url );
}
}
// Extract the processed body HTML.
$body = $dom->getElementsByTagName( 'body' )->item( 0 );
if ( ! $body instanceof DOMElement ) {
return array(
'html' => $html,
'assets' => $asset_map,
);
}
$output = '';
foreach ( $body->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMDocument API property.
$saved = $dom->saveHTML( $child );
if ( is_string( $saved ) ) {
$output .= $saved;
}
}
if ( '' === $output ) {
$output = $html;
}
return array(
'html' => $output,
'assets' => $asset_map,
);
}

View file

@ -10,21 +10,39 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Derive the AES-256-CBC key for token encryption using HKDF-SHA256.
*
* Uses wp_salt('auth') as input keying material and derives a 32-byte key
* scoped to token encryption. Replaces the <= 1.1.1 practice of feeding
* wp_salt('auth') directly to openssl_encrypt().
*
* @since 1.2.0
*
* @return string 32 raw key bytes.
*/
function robotstxt_docmd_token_key(): string {
return hash_hkdf( 'sha256', wp_salt( 'auth' ), 32, 'robotstxt-docmd-github-token' );
}
/**
* Encrypt GitHub token for storage
*
* Stored format: `v2:` followed by base64( IV || ciphertext ), encrypted with
* the HKDF-derived key. The `v2:` prefix distinguishes v2 tokens from the
* legacy (<= 1.1.1) format written by earlier versions.
*
* @since 1.0.0
*
* @param string $token Plain token.
* @return string Encrypted token, or empty string on failure.
* @return string Encrypted token (v2-prefixed), or empty string on failure.
*/
function robotstxt_docmd_encrypt_token( string $token ): string {
if ( empty( $token ) ) {
if ( '' === $token ) {
return '';
}
// Use WordPress salts for encryption key.
$key = wp_salt( 'auth' );
$key = robotstxt_docmd_token_key();
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
$iv = openssl_random_pseudo_bytes( $iv_length );
$encrypted = openssl_encrypt( $token, 'aes-256-cbc', $key, 0, $iv );
@ -33,34 +51,97 @@ function robotstxt_docmd_encrypt_token( string $token ): string {
return '';
}
return base64_encode( $iv . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return 'v2:' . base64_encode( $iv . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Decrypt GitHub token from storage
*
* Handles two storage formats:
* - `v2:`-prefixed tokens are decrypted with the HKDF-derived key.
* - Legacy (unprefixed) tokens are decrypted with wp_salt('auth') directly,
* then transparently re-encrypted with the v2 scheme and persisted (one-time
* lazy migration). This keeps the cron path working without an admin request.
*
* @since 1.0.0
*
* @param string $encrypted_token Encrypted token.
* @param string $encrypted_token Encrypted token (v2 or legacy).
* @return string Plain token, or empty string on failure.
*/
function robotstxt_docmd_decrypt_token( string $encrypted_token ): string {
if ( empty( $encrypted_token ) ) {
if ( '' === $encrypted_token ) {
return '';
}
$key = wp_salt( 'auth' );
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
// v2 tokens use the HKDF-derived key.
if ( str_starts_with( $encrypted_token, 'v2:' ) ) {
$decoded = base64_decode( substr( $encrypted_token, 3 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded || strlen( $decoded ) <= $iv_length ) {
return '';
}
$iv = substr( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length );
$decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', robotstxt_docmd_token_key(), 0, $iv );
return false !== $decrypted ? $decrypted : '';
}
// Legacy tokens (<= 1.1.1) used wp_salt('auth') directly as the key.
$decoded = base64_decode( $encrypted_token, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded || strlen( $decoded ) <= $iv_length ) {
return '';
}
$iv = substr( $decoded, 0, $iv_length );
$encrypted = substr( $decoded, $iv_length );
$decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, 0, $iv );
return false !== $decrypted ? $decrypted : '';
$iv = substr( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length );
$decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', wp_salt( 'auth' ), 0, $iv );
if ( false === $decrypted || '' === $decrypted ) {
return '';
}
// Lazy one-time migration to the v2 scheme.
$reencrypted = robotstxt_docmd_encrypt_token( $decrypted );
if ( '' !== $reencrypted ) {
robotstxt_docmd_persist_migrated_token( $reencrypted );
}
return $decrypted;
}
/**
* Persist a migrated (re-encrypted) token back into plugin settings.
*
* Runs at most once: after the first migration the stored value carries the
* `v2:` prefix and the legacy branch in robotstxt_docmd_decrypt_token() is no
* longer taken. Safe under concurrency because the re-encrypted value is
* deterministic for a given plaintext + key.
*
* @since 1.2.0
*
* @param string $reencrypted Re-encrypted token (v2 scheme).
* @return void
*/
function robotstxt_docmd_persist_migrated_token( string $reencrypted ): void {
$raw_settings = get_option( 'robotstxt_docmd_settings', array() );
if ( ! is_array( $raw_settings ) ) {
return;
}
$current = array_key_exists( 'github_token', $raw_settings ) && is_string( $raw_settings['github_token'] )
? $raw_settings['github_token']
: '';
// Only update while the stored token is still the legacy (unprefixed) one.
if ( '' === $current || str_starts_with( $current, 'v2:' ) ) {
return;
}
$raw_settings['github_token'] = $reencrypted;
update_option( 'robotstxt_docmd_settings', $raw_settings, false );
}
/**

View file

@ -135,10 +135,10 @@ function robotstxt_docmd_get_repository_contents_recursive( string $owner, strin
$url = sprintf(
'https://api.github.com/repos/%s/%s/contents/%s?ref=%s',
$owner,
$repo,
$path,
$branch
rawurlencode( $owner ),
rawurlencode( $repo ),
rawurlencode( $path ),
rawurlencode( $branch )
);
$response = wp_remote_get(
@ -239,10 +239,10 @@ function robotstxt_docmd_get_file_content( string $owner, string $repo, string $
$url = sprintf(
'https://api.github.com/repos/%s/%s/contents/%s?ref=%s',
$owner,
$repo,
$file_path,
$branch
rawurlencode( $owner ),
rawurlencode( $repo ),
rawurlencode( $file_path ),
rawurlencode( $branch )
);
$response = wp_remote_get(

View file

@ -292,6 +292,11 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error {
return $content;
}
// Derive the post title from the first H1 and remove that H1 from the body.
$extracted = robotstxt_docmd_extract_h1_title( $content, $mapping['title'] );
$content = $extracted['content'];
$post_title = $extracted['title'];
// Convert Markdown to HTML using CommonMark.
// Strip raw HTML from Markdown to prevent stored XSS via compromised upstream repos.
$converter = new \League\CommonMark\CommonMarkConverter(
@ -307,7 +312,7 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error {
// Create new post.
$post_id = wp_insert_post(
array(
'post_title' => $mapping['title'],
'post_title' => $post_title,
'post_content' => $html_content,
'post_type' => $mapping['target_post_type'],
'post_status' => 'publish',
@ -325,20 +330,35 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error {
// Save the post ID for future syncs.
update_post_meta( $mapping_id, '_robotstxt_docmd_target_post_id', $post_id );
$target_post_id = (int) $post_id;
} else {
// Update existing post.
$update_result = wp_update_post(
array(
'ID' => $mapping['target_post_id'],
'post_content' => $html_content,
),
true
);
$target_post_id = (int) $mapping['target_post_id'];
}
if ( is_wp_error( $update_result ) ) {
update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' );
return $update_result;
}
// Post-process: translate internal links and sideload repository images.
$asset_map = robotstxt_docmd_get_asset_map( $target_post_id );
$processed = robotstxt_docmd_process_synced_html( $html_content, $mapping, $target_post_id, $token, $asset_map );
if ( '' !== $processed['html'] ) {
$html_content = $processed['html'];
}
robotstxt_docmd_save_asset_map( $target_post_id, $processed['assets'] );
// Persist the rewritten content and the H1-derived title.
// NOTE: post_name (slug) is intentionally NEVER passed here, so the slug
// remains stable across syncs even though the title follows the H1.
$update_result = wp_update_post(
array(
'ID' => $target_post_id,
'post_title' => $post_title,
'post_content' => $html_content,
),
true
);
if ( is_wp_error( $update_result ) ) {
update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' );
return $update_result;
}
// Update sync status.

View file

@ -3,8 +3,8 @@
* Plugin Name: Documentation Markdown (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown
* Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically.
* Version: 1.1.1
* Requires at least: 6.7
* Version: 1.2.0
* Requires at least: 6.8
* Requires PHP: 8.0
* Security: robotstxt@robotstxt.es
* Author: ROBOTSTXT
@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// Define plugin constants.
define( 'ROBOTSTXT_DOCMD_VERSION', '1.1.1' );
define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.0' );
define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ );
define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
@ -41,6 +41,7 @@ if ( file_exists( ROBOTSTXT_DOCMD_PLUGIN_DIR . 'vendor/autoload.php' ) ) {
require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-functions.php';
require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-github.php';
require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-map.php';
require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-content.php';
require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-cron.php';
// Activation/Deactivation hooks.
@ -254,35 +255,34 @@ function robotstxt_docmd_handle_admin_actions() {
if ( isset( $_GET['page'] ) && 'robotstxt-docmd-settings' === $_GET['page'] && isset( $_GET['debug_action'] ) ) {
$debug_action = sanitize_key( wp_unslash( robotstxt_docmd_input_string( $_GET, 'debug_action' ) ) );
if ( 'test_github' === $debug_action ) {
check_admin_referer( 'robotstxt_docmd_debug_test_github' );
robotstxt_docmd_debug_test_github();
return;
}
switch ( $debug_action ) {
case 'test_github':
check_admin_referer( 'robotstxt_docmd_debug_test_github' );
robotstxt_docmd_debug_test_github();
return;
if ( 'test_token' === $debug_action ) {
check_admin_referer( 'robotstxt_docmd_debug_test_token' );
robotstxt_docmd_debug_test_token();
return;
}
case 'test_token':
check_admin_referer( 'robotstxt_docmd_debug_test_token' );
robotstxt_docmd_debug_test_token();
return;
if ( 'run_cron' === $debug_action && isset( $_GET['mapping_id'] ) ) {
$mapping_id = robotstxt_docmd_input_int( $_GET, 'mapping_id' );
check_admin_referer( 'robotstxt_docmd_debug_run_cron_' . $mapping_id );
robotstxt_docmd_debug_run_cron( $mapping_id );
return;
}
case 'run_cron':
if ( isset( $_GET['mapping_id'] ) ) {
$mapping_id = robotstxt_docmd_input_int( $_GET, 'mapping_id' );
check_admin_referer( 'robotstxt_docmd_debug_run_cron_' . $mapping_id );
robotstxt_docmd_debug_run_cron( $mapping_id );
}
return;
if ( 'clear_cache' === $debug_action ) {
check_admin_referer( 'robotstxt_docmd_debug_clear_cache' );
robotstxt_docmd_debug_clear_cache();
return;
}
case 'clear_cache':
check_admin_referer( 'robotstxt_docmd_debug_clear_cache' );
robotstxt_docmd_debug_clear_cache();
return;
if ( 'fix_crons' === $debug_action ) {
check_admin_referer( 'robotstxt_docmd_debug_fix_crons' );
robotstxt_docmd_debug_fix_crons();
return;
case 'fix_crons':
check_admin_referer( 'robotstxt_docmd_debug_fix_crons' );
robotstxt_docmd_debug_fix_crons();
return;
}
}
@ -374,11 +374,23 @@ function robotstxt_docmd_render_mappings_page() {
</a>
</td>
<td>
<?php if ( ! empty( $mapping['target_post_id'] ) ) : ?>
<a href="<?php echo esc_url( (string) get_permalink( (int) $mapping['target_post_id'] ) ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( (string) get_the_title( (int) $mapping['target_post_id'] ) ); ?>
<?php
if ( ! empty( $mapping['target_post_id'] ) ) :
$target_id = (int) $mapping['target_post_id'];
$permalink = get_permalink( $target_id );
$uri = get_page_uri( $target_id );
$post_type = get_post_type( $target_id );
?>
<a href="<?php echo esc_url( (string) $permalink ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( (string) get_the_title( $target_id ) ); ?>
</a>
<?php else : ?>
<br><small>
<code><?php echo esc_html( '/' . ( is_string( $uri ) ? $uri : '' ) ); ?></code>
<span class="description"><?php echo esc_html( is_string( $post_type ) ? $post_type : '' ); ?> #<?php echo esc_html( (string) $target_id ); ?></span>
</small>
<?php
else :
?>
<em><?php esc_html_e( 'Not created yet', 'robotstxt-documentation-markdown' ); ?></em>
<?php endif; ?>
</td>
@ -392,9 +404,9 @@ function robotstxt_docmd_render_mappings_page() {
<a href="<?php echo esc_url( admin_url( 'admin.php?page=robotstxt-docmd-add-mapping&mapping_id=' . $mapping['id'] ) ); ?>" class="button button-small">
<?php esc_html_e( 'Edit', 'robotstxt-documentation-markdown' ); ?>
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=robotstxt-docmd-mappings&action=delete&mapping_id=' . $mapping['id'] ), 'robotstxt_docmd_delete_' . $mapping['id'] ) ); ?>" class="button button-small button-link-delete" onclick="return confirm('<?php echo esc_js( __( 'Are you sure?', 'robotstxt-documentation-markdown' ) ); ?>');">
<?php esc_html_e( 'Delete', 'robotstxt-documentation-markdown' ); ?>
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=robotstxt-docmd-mappings&action=delete&mapping_id=' . $mapping['id'] ), 'robotstxt_docmd_delete_' . $mapping['id'] ) ); ?>" class="button button-small button-link-delete robotstxt-docmd-confirm" data-confirm="<?php esc_attr_e( 'Are you sure?', 'robotstxt-documentation-markdown' ); ?>">
<?php esc_html_e( 'Delete', 'robotstxt-documentation-markdown' ); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
@ -402,6 +414,19 @@ function robotstxt_docmd_render_mappings_page() {
</table>
<?php endif; ?>
</div>
<script>
// Delegated confirmation for delete actions (replaces inline onclick).
(function() {
var links = document.querySelectorAll('.robotstxt-docmd-confirm');
links.forEach(function(link) {
link.addEventListener('click', function(e) {
if (!confirm(link.getAttribute('data-confirm'))) {
e.preventDefault();
}
});
});
})();
</script>
<?php
}
@ -450,12 +475,20 @@ function robotstxt_docmd_render_discover_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$branch = sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_GET, 'branch', 'main' ) ) );
// Check if refresh requested.
// Cache key for this repository + branch.
$cache_key = sprintf( 'robotstxt_docmd_files_%s_%s_%s', $repo_data['owner'], $repo_data['repo'], $branch );
// Detect cache state BEFORE the fetch below repopulates it, so the
// "cached vs fresh" indicator reflects where the displayed data came from.
$was_cached = false !== get_transient( $cache_key );
// Check if refresh requested (CSRF-protected; forces a fresh GitHub API call).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$refresh = '1' === robotstxt_docmd_input_string( $_GET, 'refresh' );
if ( $refresh ) {
$cache_key = sprintf( 'robotstxt_docmd_files_%s_%s_%s', $repo_data['owner'], $repo_data['repo'], $branch );
check_admin_referer( 'robotstxt_docmd_discover_refresh' );
delete_transient( $cache_key );
$was_cached = false;
}
// Get files from GitHub.
@ -476,6 +509,7 @@ function robotstxt_docmd_render_discover_page() {
<form method="get" style="margin-bottom: 20px;">
<input type="hidden" name="page" value="robotstxt-docmd-discover">
<?php wp_nonce_field( 'robotstxt_docmd_discover_refresh', '_wpnonce', false ); ?>
<label>
<?php esc_html_e( 'Branch:', 'robotstxt-documentation-markdown' ); ?>
<input type="text" name="branch" value="<?php echo esc_attr( $branch ); ?>" class="regular-text">
@ -508,9 +542,7 @@ function robotstxt_docmd_render_discover_page() {
?>
<br>
<?php
$cache_key = sprintf( 'robotstxt_docmd_files_%s_%s_%s', $repo_data['owner'], $repo_data['repo'], $branch );
$cached_value = get_transient( $cache_key );
if ( false !== $cached_value ) {
if ( $was_cached ) {
echo '<span style="color: green;">✓ ' . esc_html__( 'Using cached data (24 hour cache)', 'robotstxt-documentation-markdown' ) . '</span>';
} else {
echo '<span style="color: orange;">⟲ ' . esc_html__( 'Fetching fresh data from GitHub API', 'robotstxt-documentation-markdown' ) . '</span>';
@ -761,25 +793,50 @@ function robotstxt_docmd_render_add_mapping_page() {
<label>
<?php esc_html_e( 'Select Page/Post:', 'robotstxt-documentation-markdown' ); ?>
<?php
$all_posts = get_posts(
// Only query public, sync-relevant post types (excludes the
// internal robotstxt_map CPT and attachments).
$selectable_types = get_post_types( array( 'public' => true ), 'names' );
unset( $selectable_types['attachment'] );
$existing_posts = get_posts(
array(
'post_type' => 'any',
'posts_per_page' => 100,
'post_type' => array_values( $selectable_types ),
// phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Deliberate cap for the admin "existing content" dropdown; query uses no_found_rows for performance.
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
'ignore_sticky_posts' => true,
)
);
// Build a path-based label per post so nested and duplicate
// pages are distinguishable, then sort by path to group the tree.
$options = array();
foreach ( $existing_posts as $existing ) {
$uri = get_page_uri( $existing );
$path = ( is_string( $uri ) && '' !== $uri ) ? $uri : $existing->post_name;
$depth = substr_count( $path, '/' );
$label = str_repeat( '— ', $depth )
. $existing->post_title
. ' — /' . $path
. ' (#' . (string) $existing->ID . ', ' . $existing->post_type . ')';
$options[ $path . "\0" . (string) $existing->ID ] = array(
'id' => $existing->ID,
'label' => $label,
);
}
ksort( $options );
?>
<select name="target_post_id">
<option value="0"><?php esc_html_e( 'Select a page...', 'robotstxt-documentation-markdown' ); ?></option>
<?php
foreach ( $all_posts as $post ) {
foreach ( $options as $option ) {
printf(
'<option value="%d" %s>%s (%s)</option>',
esc_attr( (string) $post->ID ),
selected( $data['target_post_id'], $post->ID, false ),
esc_html( $post->post_title ),
esc_html( $post->post_type )
'<option value="%d" %s>%s</option>',
esc_attr( (string) $option['id'] ),
selected( $data['target_post_id'], $option['id'], false ),
esc_html( $option['label'] )
);
}
?>
@ -863,13 +920,20 @@ function robotstxt_docmd_handle_save_mapping() {
$mapping_id = robotstxt_docmd_input_int( $_POST, 'mapping_id' );
// Validate target post type against registered public post types; fall back to 'page'.
$target_post_type = sanitize_key( wp_unslash( robotstxt_docmd_input_string( $_POST, 'target_post_type', 'page' ) ) );
$public_post_types = get_post_types( array( 'public' => true ), 'names' );
if ( ! in_array( $target_post_type, $public_post_types, true ) ) {
$target_post_type = 'page';
}
$data = array(
'title' => sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_POST, 'title' ) ) ),
'repo_owner' => sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_POST, 'repo_owner' ) ) ),
'repo_name' => sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_POST, 'repo_name' ) ) ),
'file_path' => sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_POST, 'file_path' ) ) ),
'branch' => sanitize_text_field( wp_unslash( robotstxt_docmd_input_string( $_POST, 'branch', 'main' ) ) ),
'target_post_type' => sanitize_key( wp_unslash( robotstxt_docmd_input_string( $_POST, 'target_post_type', 'page' ) ) ),
'target_post_type' => $target_post_type,
'target_author' => robotstxt_docmd_input_int( $_POST, 'target_author', get_current_user_id() ),
'target_parent' => robotstxt_docmd_input_int( $_POST, 'target_parent' ),
'target_order' => robotstxt_docmd_input_int( $_POST, 'target_order' ),

View file

@ -1,22 +1,22 @@
{
"name": "Documentation Markdown (by ROBOTSTXT)",
"slug": "robotstxt-documentation-markdown",
"version": "1.1.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/releases/download/1.1.1/robotstxt-documentation-markdown-1.1.1.zip",
"requires": "6.7",
"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",
"requires": "6.8",
"requires_php": "8.0",
"tested": "7.1",
"last_updated": "2026-06-08",
"last_updated": "2026-08-07",
"author": "ROBOTSTXT",
"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, and knowledge bases with version control.",
"changelog": "<h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li><strong>Core:</strong> GitHub repository synchronization system</li><li><strong>Core:</strong> Markdown to HTML conversion using CommonMark</li><li><strong>Core:</strong> Encrypted GitHub token storage (AES-256-CBC)</li><li><strong>Feature:</strong> Flexible file-to-content mapping system</li><li><strong>Feature:</strong> Custom Post Type for mapping management</li><li><strong>Feature:</strong> Configurable sync frequency (manual, hourly, twice daily, daily)</li><li><strong>Feature:</strong> Manual on-demand synchronization</li><li><strong>Feature:</strong> Support for pages, posts, and custom post types</li><li><strong>Feature:</strong> Page order (menu_order) configuration</li><li><strong>Feature:</strong> Configurable post author and parent page</li><li><strong>Admin:</strong> Complete admin interface with status monitoring</li><li><strong>Admin:</strong> Settings page for GitHub configuration</li><li><strong>Admin:</strong> Mappings management interface</li><li><strong>Debug:</strong> Built-in debug tools (visible when WP_DEBUG enabled)</li><li><strong>Debug:</strong> Test GitHub connection and token validity</li><li><strong>Debug:</strong> View and manage scheduled cron jobs</li><li><strong>Debug:</strong> Fix/reschedule broken cron jobs</li><li><strong>Debug:</strong> Cache management tools</li><li><strong>Security:</strong> Complete input sanitization and output escaping</li><li><strong>Security:</strong> Nonce verification on all forms</li><li><strong>Security:</strong> Capability checks for admin actions</li><li><strong>i18n:</strong> Full internationalization support</li><li><strong>Quality:</strong> WordPress Coding Standards compliant</li></ul>",
"description": "Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. Perfect for maintaining technical documentation, API references, knowledge bases, and more with version control.",
"changelog": "<h3>1.2.0 - 2026-08-07</h3><h4>Added</h4><ul><li><strong>Title from H1:</strong> synced post title taken from the first Markdown H1 (and stripped from the body)</li><li><strong>Internal link translation:</strong> repo-relative links rewritten to the matching WordPress permalink</li><li><strong>Image sideloading:</strong> repository images imported into the Media Library and referenced by attachment URL</li></ul><h4>Security</h4><ul><li>Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)</li><li>GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)</li><li>CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d</li></ul><h3>1.1.1 - 2026-06-08</h3><h4>Security</h4><ul><li>CommonMark raw HTML passthrough disabled; token option no longer autoloaded</li></ul><h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li><strong>Core:</strong> GitHub repository synchronization system</li><li><strong>Core:</strong> Markdown to HTML conversion using CommonMark</li><li><strong>Core:</strong> Encrypted GitHub token storage (AES-256-CBC)</li><li><strong>Feature:</strong> Flexible file-to-content mapping system</li><li><strong>Feature:</strong> Custom Post Type for mapping management</li><li><strong>Feature:</strong> Configurable sync frequency (manual, hourly, twice daily, daily)</li><li><strong>Feature:</strong> Manual on-demand synchronization</li><li><strong>Feature:</strong> Support for pages, posts, and custom post types</li><li><strong>Feature:</strong> Page order (menu_order) configuration</li><li><strong>Feature:</strong> Configurable post author and parent page</li><li><strong>Admin:</strong> Complete admin interface with status monitoring</li><li><strong>Admin:</strong> Settings page for GitHub configuration</li><li><strong>Admin:</strong> Mappings management interface</li><li><strong>Debug:</strong> Built-in debug tools (visible when WP_DEBUG enabled)</li><li><strong>Debug:</strong> Test GitHub connection and token validity</li><li><strong>Debug:</strong> View and manage scheduled cron jobs</li><li><strong>Debug:</strong> Fix/reschedule broken cron jobs</li><li><strong>Debug:</strong> Cache management tools</li><li><strong>Security:</strong> Complete input sanitization and output escaping</li><li><strong>Security:</strong> Nonce verification on all forms</li><li><strong>Security:</strong> Capability checks for admin actions</li><li><strong>i18n:</strong> Full internationalization support</li><li><strong>Quality:</strong> WordPress Coding Standards compliant</li></ul>",
"sections": {
"description": "<p><strong>Documentation Markdown</strong> is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.</p><h4>Key Features</h4><ul><li><strong>Automatic Synchronization:</strong> Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)</li><li><strong>Markdown to HTML:</strong> Convert GitHub Flavored Markdown to clean HTML using CommonMark</li><li><strong>Flexible Mapping:</strong> Map individual MD files to specific WordPress posts or pages</li><li><strong>Secure:</strong> Encrypted GitHub token storage, full input validation & output escaping</li><li><strong>Translatable:</strong> Full internationalization support (i18n/l10n ready)</li><li><strong>Multi-Repository:</strong> Sync from multiple GitHub repos simultaneously</li><li><strong>Manual Sync:</strong> On-demand synchronization from admin interface</li><li><strong>Debug Tools:</strong> Built-in debugging tools (visible when WP_DEBUG is enabled)</li></ul><h4>Use Cases</h4><ul><li>API Documentation - Keep your API docs in sync between GitHub and WordPress</li><li>Technical Documentation - Maintain version-controlled technical docs</li><li>Knowledge Base - Build a knowledge base powered by GitHub</li><li>Blog Posts - Write blog posts in Markdown with Git workflow</li><li>Product Documentation - Sync product documentation from your repository</li></ul><h4>Requirements</h4><ul><li>PHP 8.2 or higher</li><li>WordPress 6.5 or higher (single-site installations only)</li><li>GitHub Personal Access Token (free)</li></ul>",
"description": "<p><strong>Documentation Markdown</strong> is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.</p><h4>Key Features</h4><ul><li><strong>Automatic Synchronization:</strong> Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)</li><li><strong>Markdown to HTML:</strong> Convert GitHub Flavored Markdown to clean HTML using CommonMark</li><li><strong>Flexible Mapping:</strong> Map individual MD files to specific WordPress posts or pages</li><li><strong>Title from H1:</strong> Post titles are derived from the first Markdown H1 heading</li><li><strong>Link &amp; Image Translation:</strong> Repo-relative links are rewritten to permalinks; images are sideloaded into the Media Library</li><li><strong>Secure:</strong> Encrypted GitHub token storage (HKDF-SHA256), full input validation &amp; output escaping</li><li><strong>Translatable:</strong> Full internationalization support (i18n/l10n ready)</li><li><strong>Multi-Repository:</strong> Sync from multiple GitHub repos simultaneously</li><li><strong>Manual Sync:</strong> On-demand synchronization from admin interface</li><li><strong>Debug Tools:</strong> Built-in debugging tools (visible when WP_DEBUG is enabled)</li></ul><h4>Use Cases</h4><ul><li>API Documentation - Keep your API docs in sync between GitHub and WordPress</li><li>Technical Documentation - Maintain version-controlled technical docs</li><li>Knowledge Base - Build a knowledge base powered by GitHub</li><li>Blog Posts - Write blog posts in Markdown with Git workflow</li><li>Product Documentation - Sync product documentation from your repository</li></ul><h4>Requirements</h4><ul><li>PHP 8.0 or higher</li><li>WordPress 6.8 or higher</li><li>GitHub Personal Access Token (free)</li></ul>",
"installation": "<h4>Installation</h4><ol><li>Upload the plugin files to <code>/wp-content/plugins/robotstxt-documentation-markdown/</code></li><li>Activate the plugin through the 'Plugins' menu in WordPress</li><li>Navigate to 'Documentation → Settings' in the WordPress admin menu</li><li>Generate a GitHub Personal Access Token:<ul><li>Go to GitHub → Settings → Developer settings → Personal access tokens</li><li>Click 'Generate new token'</li><li>For public repositories: No specific scopes needed</li><li>For private repositories: Select <code>repo</code> scope</li></ul></li><li>Paste your token in the plugin settings and save</li><li>Create your first mapping under 'Documentation → Mappings'</li></ol><h4>Configuration</h4><ol><li>Go to <strong>Documentation → Add Mapping</strong></li><li>Fill in the repository details (owner, name, file path, branch)</li><li>Configure target content (post type, author, parent, order)</li><li>Set synchronization frequency</li><li>Save and click 'Sync Now' to perform first sync</li></ol>",
"faq": "<h4>Do I need a GitHub account?</h4><p>Yes, you need a GitHub account to generate a Personal Access Token. The token is required to access repositories (public or private).</p><h4>Can I sync from private repositories?</h4><p>Yes! When generating your GitHub Personal Access Token, make sure to select the <code>repo</code> scope for full access to private repositories.</p><h4>How often does synchronization happen?</h4><p>You can configure synchronization frequency per mapping: Manual only, Hourly, Twice daily, or Daily. You can also manually trigger sync at any time.</p><h4>Will the plugin delete my WordPress content if I uninstall it?</h4><p>By default, NO. When you uninstall the plugin, it preserves all synced WordPress pages/posts. However, there's an option in Settings to delete plugin data on uninstall - but this never deletes the actual WordPress content.</p><h4>Can I sync multiple files from the same repository?</h4><p>Yes! You can create multiple mappings, each pointing to different files in the same repository or different repositories.</p><h4>How do I debug synchronization issues?</h4><p>Enable WP_DEBUG in your wp-config.php, then go to Documentation → Settings. You'll see a 'Debug Tools' section with options to test GitHub connection, validate token, view cron jobs, and run manual syncs.</p>",
"changelog": "<h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li><strong>Core:</strong> GitHub repository synchronization system</li><li><strong>Core:</strong> Markdown to HTML conversion using CommonMark</li><li><strong>Core:</strong> Encrypted GitHub token storage (AES-256-CBC)</li><li><strong>Feature:</strong> Flexible file-to-content mapping system</li><li><strong>Feature:</strong> Custom Post Type for mapping management</li><li><strong>Feature:</strong> Configurable sync frequency (manual, hourly, twice daily, daily)</li><li><strong>Feature:</strong> Manual on-demand synchronization</li><li><strong>Feature:</strong> Support for pages, posts, and custom post types</li><li><strong>Feature:</strong> Page order (menu_order) configuration</li><li><strong>Feature:</strong> Configurable post author and parent page</li><li><strong>Admin:</strong> Complete admin interface with status monitoring</li><li><strong>Admin:</strong> Settings page for GitHub configuration</li><li><strong>Admin:</strong> Mappings management interface</li><li><strong>Debug:</strong> Built-in debug tools (visible when WP_DEBUG enabled)</li><li><strong>Debug:</strong> Test GitHub connection and token validity</li><li><strong>Debug:</strong> View and manage scheduled cron jobs</li><li><strong>Debug:</strong> Fix/reschedule broken cron jobs</li><li><strong>Debug:</strong> Cache management tools</li><li><strong>Security:</strong> Complete input sanitization and output escaping</li><li><strong>Security:</strong> Nonce verification on all forms</li><li><strong>Security:</strong> Capability checks for admin actions</li><li><strong>i18n:</strong> Full internationalization support</li><li><strong>Quality:</strong> WordPress Coding Standards compliant</li></ul>"
"changelog": "<h3>1.2.0 - 2026-08-07</h3><h4>Added</h4><ul><li><strong>Title from H1:</strong> synced post title taken from the first Markdown H1 (and stripped from the body)</li><li><strong>Internal link translation:</strong> repo-relative links rewritten to the matching WordPress permalink</li><li><strong>Image sideloading:</strong> repository images imported into the Media Library and referenced by attachment URL</li></ul><h4>Security</h4><ul><li>Patched CVE-2026-71478 in league/commonmark (2.8.2 -> 2.9.0)</li><li>GitHub token encryption hardened with HKDF-SHA256 key derivation (transparent migration)</li><li>CSRF nonce added to Discover Files refresh; GitHub API paths rawurlencode()d</li></ul><h3>1.1.1 - 2026-06-08</h3><h4>Security</h4><ul><li>CommonMark raw HTML passthrough disabled; token option no longer autoloaded</li></ul><h3>1.0.0 - 2026-01-26</h3><h4>Initial Release</h4><ul><li>Core synchronization, CommonMark conversion, encrypted token storage, mapping system, multi-repo support, debug tools</li></ul>"
},
"banners": {
"low": "",

2
vendor/autoload.php vendored
View file

@ -19,4 +19,4 @@ if (PHP_VERSION_ID < 50600) {
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit062b6bfebe9519df3bcac54aafa83aec::getLoader();
return ComposerAutoloaderInitc097ca862608a882f291ae6b935e271c::getLoader();

View file

@ -185,6 +185,8 @@ return array(
'League\\CommonMark\\Extension\\Mention\\Mention' => $vendorDir . '/league/commonmark/src/Extension/Mention/Mention.php',
'League\\CommonMark\\Extension\\Mention\\MentionExtension' => $vendorDir . '/league/commonmark/src/Extension/Mention/MentionExtension.php',
'League\\CommonMark\\Extension\\Mention\\MentionParser' => $vendorDir . '/league/commonmark/src/Extension/Mention/MentionParser.php',
'League\\CommonMark\\Extension\\NormalizeHeadings\\NormalizeHeadingsExtension' => $vendorDir . '/league/commonmark/src/Extension/NormalizeHeadings/NormalizeHeadingsExtension.php',
'League\\CommonMark\\Extension\\NormalizeHeadings\\NormalizeHeadingsProcessor' => $vendorDir . '/league/commonmark/src/Extension/NormalizeHeadings/NormalizeHeadingsProcessor.php',
'League\\CommonMark\\Extension\\SmartPunct\\DashParser' => $vendorDir . '/league/commonmark/src/Extension/SmartPunct/DashParser.php',
'League\\CommonMark\\Extension\\SmartPunct\\EllipsesParser' => $vendorDir . '/league/commonmark/src/Extension/SmartPunct/EllipsesParser.php',
'League\\CommonMark\\Extension\\SmartPunct\\Quote' => $vendorDir . '/league/commonmark/src/Extension/SmartPunct/Quote.php',
@ -361,6 +363,7 @@ return array(
'Nette\\Utils\\AssertionException' => $vendorDir . '/nette/utils/src/Utils/exceptions.php',
'Nette\\Utils\\Callback' => $vendorDir . '/nette/utils/src/Utils/Callback.php',
'Nette\\Utils\\DateTime' => $vendorDir . '/nette/utils/src/Utils/DateTime.php',
'Nette\\Utils\\DateTimeImmutable' => $vendorDir . '/nette/utils/src/Utils/DateTimeImmutable.php',
'Nette\\Utils\\FileInfo' => $vendorDir . '/nette/utils/src/Utils/FileInfo.php',
'Nette\\Utils\\FileSystem' => $vendorDir . '/nette/utils/src/Utils/FileSystem.php',
'Nette\\Utils\\Finder' => $vendorDir . '/nette/utils/src/Utils/Finder.php',

View file

@ -2,7 +2,7 @@
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit062b6bfebe9519df3bcac54aafa83aec
class ComposerAutoloaderInitc097ca862608a882f291ae6b935e271c
{
private static $loader;
@ -24,16 +24,16 @@ class ComposerAutoloaderInit062b6bfebe9519df3bcac54aafa83aec
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit062b6bfebe9519df3bcac54aafa83aec', 'loadClassLoader'), true, true);
spl_autoload_register(array('ComposerAutoloaderInitc097ca862608a882f291ae6b935e271c', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit062b6bfebe9519df3bcac54aafa83aec', 'loadClassLoader'));
spl_autoload_unregister(array('ComposerAutoloaderInitc097ca862608a882f291ae6b935e271c', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec::getInitializer($loader));
call_user_func(\Composer\Autoload\ComposerStaticInitc097ca862608a882f291ae6b935e271c::getInitializer($loader));
$loader->register(true);
$filesToLoad = \Composer\Autoload\ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec::$files;
$filesToLoad = \Composer\Autoload\ComposerStaticInitc097ca862608a882f291ae6b935e271c::$files;
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;

View file

@ -4,7 +4,7 @@
namespace Composer\Autoload;
class ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec
class ComposerStaticInitc097ca862608a882f291ae6b935e271c
{
public static $files = array (
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
@ -243,6 +243,8 @@ class ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec
'League\\CommonMark\\Extension\\Mention\\Mention' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Mention/Mention.php',
'League\\CommonMark\\Extension\\Mention\\MentionExtension' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Mention/MentionExtension.php',
'League\\CommonMark\\Extension\\Mention\\MentionParser' => __DIR__ . '/..' . '/league/commonmark/src/Extension/Mention/MentionParser.php',
'League\\CommonMark\\Extension\\NormalizeHeadings\\NormalizeHeadingsExtension' => __DIR__ . '/..' . '/league/commonmark/src/Extension/NormalizeHeadings/NormalizeHeadingsExtension.php',
'League\\CommonMark\\Extension\\NormalizeHeadings\\NormalizeHeadingsProcessor' => __DIR__ . '/..' . '/league/commonmark/src/Extension/NormalizeHeadings/NormalizeHeadingsProcessor.php',
'League\\CommonMark\\Extension\\SmartPunct\\DashParser' => __DIR__ . '/..' . '/league/commonmark/src/Extension/SmartPunct/DashParser.php',
'League\\CommonMark\\Extension\\SmartPunct\\EllipsesParser' => __DIR__ . '/..' . '/league/commonmark/src/Extension/SmartPunct/EllipsesParser.php',
'League\\CommonMark\\Extension\\SmartPunct\\Quote' => __DIR__ . '/..' . '/league/commonmark/src/Extension/SmartPunct/Quote.php',
@ -419,6 +421,7 @@ class ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec
'Nette\\Utils\\AssertionException' => __DIR__ . '/..' . '/nette/utils/src/Utils/exceptions.php',
'Nette\\Utils\\Callback' => __DIR__ . '/..' . '/nette/utils/src/Utils/Callback.php',
'Nette\\Utils\\DateTime' => __DIR__ . '/..' . '/nette/utils/src/Utils/DateTime.php',
'Nette\\Utils\\DateTimeImmutable' => __DIR__ . '/..' . '/nette/utils/src/Utils/DateTimeImmutable.php',
'Nette\\Utils\\FileInfo' => __DIR__ . '/..' . '/nette/utils/src/Utils/FileInfo.php',
'Nette\\Utils\\FileSystem' => __DIR__ . '/..' . '/nette/utils/src/Utils/FileSystem.php',
'Nette\\Utils\\Finder' => __DIR__ . '/..' . '/nette/utils/src/Utils/Finder.php',
@ -460,9 +463,9 @@ class ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInit062b6bfebe9519df3bcac54aafa83aec::$classMap;
$loader->prefixLengthsPsr4 = ComposerStaticInitc097ca862608a882f291ae6b935e271c::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInitc097ca862608a882f291ae6b935e271c::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInitc097ca862608a882f291ae6b935e271c::$classMap;
}, null, ClassLoader::class);
}

View file

@ -80,17 +80,17 @@
},
{
"name": "league/commonmark",
"version": "2.8.2",
"version_normalized": "2.8.2.0",
"version": "2.9.0",
"version_normalized": "2.9.0.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "59fb075d2101740c337c7216e3f32b36c204218b"
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
"reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"shasum": ""
},
"require": {
@ -112,8 +112,8 @@
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
@ -124,11 +124,11 @@
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"time": "2026-03-19T13:16:38+00:00",
"time": "2026-08-03T13:42:31+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
"dev-main": "2.10-dev"
}
},
"installation-source": "dist",
@ -345,17 +345,17 @@
},
{
"name": "nette/utils",
"version": "v4.1.4",
"version_normalized": "4.1.4.0",
"version": "v4.1.5",
"version_normalized": "4.1.5.0",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
"reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"shasum": ""
},
"require": {
@ -375,13 +375,13 @@
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"time": "2026-05-11T20:49:54+00:00",
"time": "2026-07-17T23:02:45+00:00",
"type": "library",
"extra": {
"branch-alias": {
@ -433,7 +433,7 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
"source": "https://github.com/nette/utils/tree/v4.1.5"
},
"install-path": "../nette/utils"
},
@ -492,23 +492,23 @@
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.0",
"version_normalized": "3.7.0.0",
"version": "v3.7.1",
"version_normalized": "3.7.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"time": "2026-04-13T15:52:40+00:00",
"time": "2026-06-05T06:23:12+00:00",
"type": "library",
"extra": {
"thanks": {
@ -542,7 +542,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{

View file

@ -20,9 +20,9 @@
'dev_requirement' => false,
),
'league/commonmark' => array(
'pretty_version' => '2.8.2',
'version' => '2.8.2.0',
'reference' => '59fb075d2101740c337c7216e3f32b36c204218b',
'pretty_version' => '2.9.0',
'version' => '2.9.0.0',
'reference' => '5703d83ba3da3b2e356a5fedc848ed6d8ffb6529',
'type' => 'library',
'install_path' => __DIR__ . '/../league/commonmark',
'aliases' => array(),
@ -47,9 +47,9 @@
'dev_requirement' => false,
),
'nette/utils' => array(
'pretty_version' => 'v4.1.4',
'version' => '4.1.4.0',
'reference' => '7da6c396d7ebe142bc857c20479d5e70a5e1aac7',
'pretty_version' => 'v4.1.5',
'version' => '4.1.5.0',
'reference' => 'b043439dbdf954e6c28b5ea7e34b0100f83165e0',
'type' => 'library',
'install_path' => __DIR__ . '/../nette/utils',
'aliases' => array(),
@ -74,9 +74,9 @@
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.7.0',
'version' => '3.7.0.0',
'reference' => '50f59d1f3ca46d41ac911f97a78626b6756af35b',
'pretty_version' => 'v3.7.1',
'version' => '3.7.1.0',
'reference' => 'f3202fa1b5097b0af062dc978b32ecf63404e31d',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
'aliases' => array(),

View file

@ -36,6 +36,8 @@ namespace PHPSTORM_META
'renderer/block_separator',
'renderer/inner_separator',
'renderer/soft_break',
'xml',
'xml/max_indentation_level',
'commonmark',
'commonmark/enable_em',
'commonmark/enable_strong',

View file

@ -6,6 +6,45 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
## [Unreleased][unreleased]
## [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.
### Added
- Added a new `NormalizeHeadingsExtension` to constrain headings to a configured level range (#989)
- Rewrites headings that skip levels so the resulting HTML is valid (#1115)
- `normalize_headings/rebase_to_min_level` - rebases each document so its headings begin at `min_level`
- Added a new `footnote/enable_inline_footnotes` config option to disable the inline `^[Footnote text]` syntax (#1112)
- Added `Cursor::getBytePosition()` for obtaining the cursor's current byte offset within the line
- Added a new `xml/max_indentation_level` config option to control how far `XmlRenderer` indents nested elements (default: `16`; set to `0` for unindented output)
### Changed
- The `FootnoteExtension` now uses only the first definition of a footnote label, removing any duplicate definitions instead of rendering them in place
- `NumberFootnotesListener` now stores footnote backrefs under a single `footnote/backrefs` key in the document data instead of one key per footnote destination
- Optimized `Cursor` to translate character positions to byte offsets in constant time instead of re-decoding the line with `mb_substr()`
- Optimized `Cursor::match()` to match against the line at the cursor's byte offset instead of copying the remaining line on every call
- Optimized `InlineParserEngine` and `UrlAutolinkParser` to work with byte offsets directly
### Fixed
- Fixed quadratic parsing performance on lines containing multibyte characters, which could be abused to cause a denial of service (GHSA-2q4p-g7hv-5rgv)
- Fixed the unsafe link filter failing to detect dangerous schemes obfuscated with embedded tabs, newlines, or leading control characters (such as `java<TAB>script:`), which allowed the `allow_unsafe_links` protection to be bypassed via `href` and `src` attributes (GHSA-29pj-957v-52mc)
- Fixed duplicate footnote definitions each claiming the full list of backrefs for their label, causing a quadratic number of backrefs to be generated, which could be abused to cause a denial of service (GHSA-jfm3-95jq-q3rf)
- Fixed footnote labels being treated as `.`/`/`-delimited key paths when storing backrefs, which allowed distinct labels such as `[^a.b]` and `[^a/b]` to share a single backref list (GHSA-jfm3-95jq-q3rf)
- Fixed a fatal error when one footnote label was a prefix of another, such as `[^a]` and `[^a.b]`
- Fixed the unique slug normalizer restarting its suffix search from `1` on every collision, causing headings or inline footnotes which normalize to the same slug to be de-duplicated in quadratic time, which could be abused to cause a denial of service (GHSA-mh25-x5hq-wrqp)
- Fixed the `AttributesExtension` scanning the remaining siblings of an inline attribute which can only apply to its parent block, causing long runs of adjacent inline attributes to be resolved in quadratic time, which could be abused to cause a denial of service (GHSA-g2gp-3wwq-f4ph)
- Fixed `XmlRenderer` indenting every element by its full nesting depth without any upper bound, causing deeply-nested documents to render as quadratically-sized XML, which could be abused to cause a denial of service (GHSA-mj63-m3rc-8ppr)
- Fixed `MarkDelimiterProcessor` not being declared as a `CacheableDelimiterProcessorInterface`, preventing the delimiter stack from caching the opener search for `==` runs (#1133)
## [2.8.3] - 2026-07-12
### Fixed
- Fixed tab-indented fenced code blocks inside list items losing the first character of each line and having their info string mangled (#981, #1130)
- Fixed the unsafe link filter incorrectly blocking safe URLs containing `vbscript:`, `file:`, or `data:` anywhere after the start (#1131)
## [2.8.2] - 2026-03-19
This is a **security release** to address an issue where the `allowed_domains` setting for the `Embed` extension can be bypassed, resulting in a possible SSRF and XSS vulnerabilities.
@ -732,7 +771,9 @@ 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.8.2...HEAD
[unreleased]: https://github.com/thephpleague/commonmark/compare/2.9.0...HEAD
[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
[2.8.1]: https://github.com/thephpleague/commonmark/compare/2.8.0...2.8.1
[2.8.0]: https://github.com/thephpleague/commonmark/compare/2.7.1...2.8.0

View file

@ -181,6 +181,7 @@ We'd also like to extend our sincere thanks the following sponsors who support o
- [Tidelift](https://tidelift.com/subscription/pkg/packagist-league-commonmark?utm_source=packagist-league-commonmark&utm_medium=referral&utm_campaign=readme) for offering support to both the maintainers and end-users through their [professional support](https://tidelift.com/subscription/pkg/packagist-league-commonmark?utm_source=packagist-league-commonmark&utm_medium=referral&utm_campaign=readme) program
- [Blackfire](https://www.blackfire.io/) for providing an Open-Source Profiler subscription
- [JetBrains](https://www.jetbrains.com/) for supporting this project with complimentary [PhpStorm](https://www.jetbrains.com/phpstorm/) licenses
- [Anthropic](https://www.anthropic.com/) for providing complimentary access to Claude Max through their [Open Source Program](https://claude.com/contact-sales/claude-for-oss)
Are you interested in sponsoring development of this project? See <https://www.colinodell.com/sponsor> for a list of ways to contribute.

View file

@ -39,8 +39,8 @@
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
@ -116,7 +116,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
"dev-main": "2.10-dev"
}
},
"config": {

View file

@ -50,7 +50,7 @@ final class DelimiterStack
/** @psalm-suppress PropertyTypeCoercion */
$this->missingIndexCache = new \WeakMap(); // @phpstan-ignore-line
} else {
$this->missingIndexCache = new \SplObjectStorage(); // @phpstan-ignore-line
$this->missingIndexCache = new \SplObjectStorage();
}
}

View file

@ -438,6 +438,9 @@ final class Environment implements EnvironmentInterface, EnvironmentBuilderInter
'inner_separator' => Expect::string("\n"),
'soft_break' => Expect::string("\n"),
]),
'xml' => Expect::structure([
'max_indentation_level' => Expect::int()->min(0)->default(16),
]),
'slug_normalizer' => Expect::structure([
'instance' => Expect::type(TextNormalizerInterface::class)->default(new SlugNormalizer()),
'max_length' => Expect::int()->min(0)->default(255),

View file

@ -94,7 +94,16 @@ final class AttributesListener
}
if ($node instanceof AttributesInline && ($previous === null || ($previous instanceof AbstractInline && $node->isBlock()))) {
continue;
// Once this condition holds it holds for every remaining iteration, as walking
// further to the left can only ever yield another inline sibling or null. No
// sibling can therefore be chosen, so the target must be the parent; continuing
// to walk would only re-scan the remaining siblings for nothing.
if (! $node->parent() instanceof FencedCode) {
$target = $node->parent();
$direction = self::DIRECTION_SUFFIX;
}
break;
}
if ($previous !== null && ! self::isAttributesNode($previous)) {

View file

@ -23,7 +23,7 @@ final class UrlAutolinkParser implements InlineParserInterface
private const ALLOWED_AFTER = [null, ' ', "\t", "\n", "\x0b", "\x0c", "\x0d", '*', '_', '~', '('];
// RegEx adapted from https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/Validator/Constraints/UrlValidator.php
private const REGEX = '~^
private const REGEX = '~
(
# Must start with a supported scheme + auth, or "www"
(?:
@ -49,7 +49,7 @@ final class UrlAutolinkParser implements InlineParserInterface
(?:/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|%%[0-9A-Fa-f]{2})* )* # a path
(?:\? (?:[\pL\pN\-._\~!$&\'\[\]()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a query (optional)
(?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a fragment (optional)
)~ixu';
)~ixuA';
/**
* @var string[]
@ -99,8 +99,11 @@ final class UrlAutolinkParser implements InlineParserInterface
return false;
}
// Check if we have a valid URL
if (! \preg_match($this->finalRegex, $cursor->getRemainder(), $matches)) {
// Check if we have a valid URL. The regex is anchored (the "A" modifier) and matched
// against the full line at the current byte offset rather than against a fresh copy of
// the remaining text. This avoids re-allocating and re-validating (the "u" modifier) the
// entire remainder on every prefix occurrence, which would otherwise be quadratic.
if (! \preg_match($this->finalRegex, $cursor->getLine(), $matches, 0, $cursor->getBytePosition())) {
return false;
}

View file

@ -51,10 +51,11 @@ final class FencedCodeParser extends AbstractBlockContinueParser
}
}
// Skip optional spaces of fence offset
// Optimization: don't attempt to match if we're at a non-space position
if ($cursor->getNextNonSpacePosition() > $cursor->getPosition()) {
$cursor->match('/^ {0,' . $this->block->getOffset() . '}/');
// Skip optional spaces of fence offset, counting columns instead of characters
// so that tabs are only partially consumed when needed
$fenceOffset = $this->block->getOffset();
while ($fenceOffset > 0 && $cursor->advanceBySpaceOrTab()) {
$fenceOffset--;
}
return BlockContinue::at($cursor);

View file

@ -30,14 +30,42 @@ final class GatherFootnotesListener implements ConfigurationAwareInterface
public function onDocumentParsed(DocumentParsedEvent $event): void
{
$document = $event->getDocument();
$footnotes = [];
$document = $event->getDocument();
$footnotes = [];
$definitions = [];
$discarded = [];
/** @var array<string, Reference[]> $backrefs */
$backrefs = $document->data->get('footnote/backrefs', []);
/*
* A label may be defined more than once. Only the first definition is used, matching how
* duplicate link reference definitions are resolved; the rest are discarded below.
*
* Keeping just one definition per label is also what bounds the work done here: every
* definition sharing a label claims that label's entire backref list, so N duplicate
* definitions of a label referenced M times would otherwise produce M * N backrefs - a
* denial of service vector, since both are attacker-controlled.
*
* Nodes are collected rather than detached here because detaching mid-iteration would
* truncate the walk.
*/
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if (! $node instanceof Footnote) {
continue;
}
$label = $node->getReference()->getLabel();
if (isset($definitions[$label])) {
$discarded[] = $node;
continue;
}
$definitions[$label] = $node;
}
foreach ($definitions as $node) {
// Look for existing reference with footnote label
$ref = $document->getReferenceMap()->get($node->getReference()->getLabel());
if ($ref !== null) {
@ -49,11 +77,15 @@ final class GatherFootnotesListener implements ConfigurationAwareInterface
}
$key = '#' . $this->config->get('footnote/footnote_id_prefix') . $node->getReference()->getDestination();
if ($document->data->has($key)) {
$this->createBackrefs($node, $document->data->get($key));
if (isset($backrefs[$key])) {
$this->createBackrefs($node, $backrefs[$key]);
}
}
foreach ($discarded as $duplicate) {
$duplicate->detach();
}
// Only add a footnote container if there are any
if (\count($footnotes) === 0) {
return;

View file

@ -26,6 +26,7 @@ final class NumberFootnotesListener
$nextCounter = 1;
$usedLabels = [];
$usedCounters = [];
$backrefs = [];
foreach ($document->iterator() as $node) {
if (! $node instanceof FootnoteRef) {
@ -59,10 +60,15 @@ final class NumberFootnotesListener
$document->getReferenceMap()->add($newReference);
/*
* Store created references in document for
* creating FootnoteBackrefs
* Store created references for creating FootnoteBackrefs.
*
* These are collected into a plain array keyed by the exact destination rather than
* written straight into $document->data: destinations are built from user-supplied
* labels, and Data treats both "." and "/" as key path delimiters. Using them as
* paths would let distinct labels collapse onto a shared entry, or nest one label's
* entry inside another's.
*/
$document->data->append($existingReference->getDestination(), $newReference);
$backrefs[$existingReference->getDestination()][] = $newReference;
$usedLabels[$label] = 1;
$usedCounters[$label] = $nextCounter;
@ -71,5 +77,7 @@ final class NumberFootnotesListener
$nextCounter++;
}
}
$document->data->set('footnote/backrefs', $backrefs);
}
}

View file

@ -44,6 +44,7 @@ final class FootnoteExtension implements ConfigurableExtensionInterface
'backref_symbol' => Expect::string('↩'),
'container_add_hr' => Expect::bool(true),
'container_class' => Expect::string('footnotes'),
'enable_inline_footnotes' => Expect::bool(true),
'ref_class' => Expect::string('footnote-ref'),
'ref_id_prefix' => Expect::string('fnref:'),
'footnote_class' => Expect::string('footnote'),
@ -53,8 +54,12 @@ final class FootnoteExtension implements ConfigurableExtensionInterface
public function register(EnvironmentBuilderInterface $environment): void
{
if ($environment->getConfiguration()->get('footnote/enable_inline_footnotes')) {
$environment->addInlineParser(new AnonymousFootnoteRefParser(), 35);
$environment->addEventListener(DocumentParsedEvent::class, [new AnonymousFootnotesListener(), 'onDocumentParsed'], 40);
}
$environment->addBlockStartParser(new FootnoteStartParser(), 51);
$environment->addInlineParser(new AnonymousFootnoteRefParser(), 35);
$environment->addInlineParser(new FootnoteRefParser(), 51);
$environment->addRenderer(FootnoteContainer::class, new FootnoteContainerRenderer());
@ -62,7 +67,6 @@ final class FootnoteExtension implements ConfigurableExtensionInterface
$environment->addRenderer(FootnoteRef::class, new FootnoteRefRenderer());
$environment->addRenderer(FootnoteBackref::class, new FootnoteBackrefRenderer());
$environment->addEventListener(DocumentParsedEvent::class, [new AnonymousFootnotesListener(), 'onDocumentParsed'], 40);
$environment->addEventListener(DocumentParsedEvent::class, [new FixOrphanedFootnotesAndRefsListener(), 'onDocumentParsed'], 30);
$environment->addEventListener(DocumentParsedEvent::class, [new NumberFootnotesListener(), 'onDocumentParsed'], 20);
$environment->addEventListener(DocumentParsedEvent::class, [new GatherFootnotesListener(), 'onDocumentParsed'], 10);

View file

@ -14,10 +14,10 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Highlight;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
use League\CommonMark\Node\Inline\AbstractStringContainer;
class MarkDelimiterProcessor implements DelimiterProcessorInterface
class MarkDelimiterProcessor implements CacheableDelimiterProcessorInterface
{
public function getOpeningCharacter(): string
{

View file

@ -41,14 +41,14 @@ final class CallbackGenerator implements MentionGeneratorInterface
return null;
}
if ($result instanceof AbstractInline && ! ($result instanceof Mention)) {
if ($result instanceof Mention) {
if ($result->hasUrl()) {
return $mention;
}
} elseif ($result instanceof AbstractInline) {
return $result;
}
if ($result instanceof Mention && $result->hasUrl()) {
return $mention;
}
throw new LogicException('CallbackGenerator callable must set the URL on the passed mention and return the mention, return a new AbstractInline based object or null if the mention is not a match');
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\NormalizeHeadings;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class NormalizeHeadingsExtension implements ConfigurableExtensionInterface
{
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('normalize_headings', Expect::structure([
'min_level' => Expect::int()->min(1)->max(6)->default(1),
'max_level' => Expect::int()->min(1)->max(6)->default(6),
'rebase_to_min_level' => Expect::bool()->default(false),
])->assert(
static function (\stdClass $config): bool {
$headingLevels = (array) $config;
return $headingLevels['min_level'] <= $headingLevels['max_level'];
},
'"min_level" must be less than or equal to "max_level"'
));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addEventListener(DocumentParsedEvent::class, new NormalizeHeadingsProcessor(), -99);
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\CommonMark\Extension\NormalizeHeadings;
use League\CommonMark\Environment\EnvironmentAwareInterface;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Node\NodeIterator;
use League\Config\ConfigurationInterface;
final class NormalizeHeadingsProcessor implements EnvironmentAwareInterface
{
/** @psalm-readonly-allow-private-mutation */
private ConfigurationInterface $config;
public function setEnvironment(EnvironmentInterface $environment): void
{
$this->config = $environment->getConfiguration();
}
public function __invoke(DocumentParsedEvent $event): void
{
$minLevel = (int) $this->config->get('normalize_headings/min_level');
$maxLevel = (int) $this->config->get('normalize_headings/max_level');
$rebaseToMinLevel = (bool) $this->config->get('normalize_headings/rebase_to_min_level');
/**
* The headings the current one is nested within, tracked by both their original level (which
* determines that nesting) and their new level (which limits how far the current one may descend)
*
* @var array<int, array{original: int, output: int}> $ancestors
*/
$ancestors = [];
foreach ($event->getDocument()->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if (! $node instanceof Heading) {
continue;
}
$level = $node->getLevel();
// Pop any headings this one isn't nested within - they're siblings or cousins, not ancestors
$parent = \end($ancestors);
while ($parent !== false && $level <= $parent['original']) {
\array_pop($ancestors);
$parent = \end($ancestors);
}
if ($parent === false) {
$newLevel = $rebaseToMinLevel ? $minLevel : self::clamp($level, $minLevel, $maxLevel);
} else {
// Place this heading exactly one level below its parent, regardless of the level it was
// written at - unless that would exceed the maximum, in which case it becomes a sibling
$newLevel = \min($parent['output'] + 1, $maxLevel);
}
$node->setLevel($newLevel);
$ancestors[] = ['original' => $level, 'output' => $newLevel];
}
}
private static function clamp(int $level, int $minLevel, int $maxLevel): int
{
return \max($minLevel, \min($level, $maxLevel));
}
}

View file

@ -60,11 +60,7 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
$this->normalizationStrategy = $normalizationStrategy;
$this->minHeadingLevel = $minHeadingLevel;
$this->maxHeadingLevel = $maxHeadingLevel;
$this->fragmentPrefix = $fragmentPrefix;
if ($fragmentPrefix !== '') {
$this->fragmentPrefix .= '-';
}
$this->fragmentPrefix = $fragmentPrefix === '' ? '' : $fragmentPrefix . '-';
}
public function generate(Document $document): ?TableOfContents

View file

@ -17,7 +17,11 @@ namespace League\CommonMark\Normalizer;
final class UniqueSlugNormalizer implements UniqueSlugNormalizerInterface
{
private TextNormalizerInterface $innerNormalizer;
/** @var array<string, bool> */
/**
* Every slug we've handed out, mapped to the next numeric suffix to try for it
*
* @var array<string, int>
*/
private array $alreadyUsed = [];
public function __construct(TextNormalizerInterface $innerNormalizer)
@ -39,17 +43,21 @@ final class UniqueSlugNormalizer implements UniqueSlugNormalizerInterface
{
$normalized = $this->innerNormalizer->normalize($text, $context);
// If it's not unique, add an incremental number to the end until we get a unique version
if (\array_key_exists($normalized, $this->alreadyUsed)) {
$suffix = 0;
do {
// If it's not unique, add an incremental number to the end until we get a unique version.
// Suffixes are handed out in ascending order and are never given back, so we can pick up
// where the previous collision left off instead of re-checking suffixes we know are taken.
if (isset($this->alreadyUsed[$normalized])) {
$suffix = $this->alreadyUsed[$normalized];
while (isset($this->alreadyUsed["$normalized-$suffix"])) {
++$suffix;
} while (\array_key_exists("$normalized-$suffix", $this->alreadyUsed));
}
$this->alreadyUsed[$normalized] = $suffix + 1;
$normalized = "$normalized-$suffix";
}
$this->alreadyUsed[$normalized] = true;
$this->alreadyUsed[$normalized] = 1;
return $normalized;
}

View file

@ -19,6 +19,15 @@ class Cursor
{
public const INDENT_LEVEL = 4;
/**
* Interval (in characters) between recorded byte-offset checkpoints on multibyte lines.
* A larger interval uses less memory but makes random lookups walk further; a smaller one
* costs more memory but speeds random lookups. Sequential access is O(1) regardless, via the
* last-resolved-position cache, so this only trades memory against the cost of random jumps
* (backwards peeks, saveState/restoreState).
*/
private const BYTE_OFFSET_CHECKPOINT_INTERVAL = 16;
/** @psalm-readonly */
private string $line;
@ -53,7 +62,34 @@ class Cursor
/** @psalm-readonly */
private bool $isMultibyte;
/** @var array<int, string> */
/**
* Sparse lookup table mapping a checkpoint index (character index divided by
* BYTE_OFFSET_CHECKPOINT_INTERVAL) to the byte offset at which that character begins within
* $line. Only populated for multibyte lines, and only ever extended forwards. Recording one
* offset per interval - rather than one per character - bounds this map to O(n / interval)
* memory, so it can never become the dominant allocation on a long line. A lookup walks at most
* `interval` bytes from the nearest checkpoint (or O(1) from the last-resolved position for the
* sequential access that dominates parsing); this is what keeps character<->byte translation -
* and therefore every substring accessor - linear rather than O(n^2) on multibyte lines.
*
* @var array<int, int>
*/
private array $byteOffsetCheckpoints = [];
/** Character index of the most recently resolved byte offset (paired with $lastByteOffset). */
private int $lastByteOffsetPosition = 0;
/** Byte offset of the most recently resolved character index (paired with $lastByteOffsetPosition). */
private int $lastByteOffset = 0;
/**
* Small cache of individually-read single characters on multibyte lines, keyed by
* character index. Only populated for characters actually read one-at-a-time (e.g.
* repeated peek() during delimiter scanning), so it stays tiny; substring accessors
* bypass it entirely. Avoids re-slicing the same character on every lookup.
*
* @var array<int, string>
*/
private array $charCache = [];
/**
@ -69,6 +105,81 @@ class Cursor
$this->length = \mb_strlen($line, 'UTF-8') ?: 0;
$this->isMultibyte = $this->length !== \strlen($line);
$this->lastTabPosition = $this->isMultibyte ? \mb_strrpos($line, "\t", 0, 'UTF-8') : \strrpos($line, "\t");
if (! $this->isMultibyte) {
return;
}
// Seed the checkpoint map with the start of the line; it is extended on demand (see byteOffset()).
$this->byteOffsetCheckpoints = [0];
}
/**
* Translate a character index into its byte offset within $line.
*
* The scan starts from the closest known reference point - the last-resolved position when it
* sits between the target and the nearest earlier checkpoint (the common sequential case, O(1)),
* otherwise that checkpoint (at most `interval` characters away). It then walks forwards one
* character at a time, skipping UTF-8 continuation bytes (0b10xxxxxx) and recording a checkpoint
* every `interval` characters. Sequential access is therefore amortized O(1) and any random
* lookup is bounded by O(interval), all while the map itself stays O(n / interval) in memory.
*/
private function byteOffset(int $index): int
{
if ($index === $this->lastByteOffsetPosition) {
return $this->lastByteOffset;
}
// Start from the checkpoint at or before $index, or - if we've not scanned that far yet -
// from the furthest checkpoint recorded so far.
$checkpoint = \intdiv($index, self::BYTE_OFFSET_CHECKPOINT_INTERVAL);
if (! isset($this->byteOffsetCheckpoints[$checkpoint])) {
$checkpoint = \count($this->byteOffsetCheckpoints) - 1;
}
$position = $checkpoint * self::BYTE_OFFSET_CHECKPOINT_INTERVAL;
$byte = $this->byteOffsetCheckpoints[$checkpoint];
// Prefer the last-resolved position when it is a closer starting point (ahead of the chosen
// checkpoint but not past the target). This makes sequential forward access O(1).
if ($this->lastByteOffsetPosition > $position && $this->lastByteOffsetPosition <= $index) {
$position = $this->lastByteOffsetPosition;
$byte = $this->lastByteOffset;
}
$lineBytes = \strlen($this->line);
while ($position < $index) {
$byte++;
while ($byte < $lineBytes && (\ord($this->line[$byte]) & 0xC0) === 0x80) {
$byte++;
}
$position++;
if ($position % self::BYTE_OFFSET_CHECKPOINT_INTERVAL === 0) {
$this->byteOffsetCheckpoints[\intdiv($position, self::BYTE_OFFSET_CHECKPOINT_INTERVAL)] = $byte;
}
}
$this->lastByteOffsetPosition = $index;
$this->lastByteOffset = $byte;
return $byte;
}
/**
* 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.
*/
private function charAt(int $index): string
{
if (isset($this->charCache[$index])) {
return $this->charCache[$index];
}
$startByte = $this->byteOffset($index);
return $this->charCache[$index] = \substr($this->line, $startByte, $this->byteOffset($index + 1) - $startByte);
}
/**
@ -86,13 +197,17 @@ class Cursor
$cols = $this->column;
for ($i = $this->currentPosition; $i < $this->length; $i++) {
// This if-else was copied out of getCharacter() for performance reasons
if ($this->isMultibyte) {
$c = $this->charCache[$i] ??= \mb_substr($this->line, $i, 1, 'UTF-8');
} else {
$c = $this->line[$i];
}
// Spaces (0x20) and tabs (0x09) are single-byte ASCII characters which can
// never appear inside a multibyte UTF-8 sequence, so the leading run of
// spaces/tabs is always scanned at the byte level. For multibyte lines this
// avoids calling mb_substr() once per character - each such call decodes from
// the start of the string (O(i)), making the scan O(n^2) over a long run of
// leading whitespace. Because every character in the run occupies exactly one
// byte, the character index and byte offset advance together.
$byteOffset = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
for ($i = $this->currentPosition; $i < $this->length; $i++, $byteOffset++) {
$c = $this->line[$byteOffset];
if ($c === ' ') {
$cols++;
@ -119,7 +234,7 @@ class Cursor
}
if ($this->isMultibyte) {
return $this->charCache[$index] ??= \mb_substr($this->line, $index, 1, 'UTF-8');
return $this->charAt($index);
}
return $this->line[$index];
@ -161,7 +276,7 @@ class Cursor
}
if ($this->isMultibyte) {
return $this->charCache[$index] ??= \mb_substr($this->line, $index, 1, 'UTF-8');
return $this->charAt($index);
}
return $this->line[$index];
@ -177,7 +292,7 @@ class Cursor
}
if ($this->isMultibyte) {
return $this->charCache[$this->currentPosition] ??= \mb_substr($this->line, $this->currentPosition, 1, 'UTF-8');
return $this->charAt($this->currentPosition);
}
return $this->line[$this->currentPosition];
@ -232,14 +347,18 @@ class Cursor
return;
}
$nextFewChars = $this->isMultibyte ?
\mb_substr($this->line, $this->currentPosition, $characters, 'UTF-8') :
\substr($this->line, $this->currentPosition, $characters);
if ($this->isMultibyte) {
$startByte = $this->byteOffset($this->currentPosition);
$endByte = $this->byteOffset(\min($this->currentPosition + $characters, $this->length));
$nextFewChars = \substr($this->line, $startByte, $endByte - $startByte);
} else {
$nextFewChars = \substr($this->line, $this->currentPosition, $characters);
}
if ($characters === 1) {
$asArray = [$nextFewChars];
} elseif ($this->isMultibyte) {
/** @var string[] $asArray */
/** @var list<string> $asArray */
$asArray = \mb_str_split($nextFewChars, 1, 'UTF-8');
} else {
$asArray = \str_split($nextFewChars);
@ -374,7 +493,7 @@ class Cursor
}
$subString = $this->isMultibyte ?
\mb_substr($this->line, $position, null, 'UTF-8') :
\substr($this->line, $this->byteOffset($position)) :
\substr($this->line, $position);
return $prefix . $subString;
@ -398,6 +517,57 @@ class Cursor
* @psalm-param non-empty-string $regex
*/
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);
}
// 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);
}
$bytePosition = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
if (! \preg_match($regex, $this->line, $matches, \PREG_OFFSET_CAPTURE, $bytePosition)) {
return null;
}
// $matches[0][0] contains the matched text; $matches[0][1] is its absolute byte offset in the line.
if ($this->isMultibyte) {
// Convert the byte offset to a character advance relative to the cursor. The scanned gap
// is only the distance from the cursor to the match (zero for anchored patterns), never
// the whole line, so this stays linear across repeated calls.
$offset = \mb_strlen(\substr($this->line, $bytePosition, $matches[0][1] - $bytePosition), 'UTF-8');
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
} else {
$offset = $matches[0][1] - $this->currentPosition;
$matchLength = \strlen($matches[0][0]);
}
$this->advanceBy($offset + $matchLength);
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();
@ -405,11 +575,7 @@ class Cursor
return null;
}
// $matches[0][0] contains the matched text
// $matches[0][1] contains the index of that match
if ($this->isMultibyte) {
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
} else {
@ -417,9 +583,21 @@ class Cursor
$matchLength = \strlen($matches[0][0]);
}
// [0][0] contains the matched text
// [0][1] contains the index of that match
$this->advanceBy($offset + $matchLength);
$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];
}
@ -465,10 +643,28 @@ class Cursor
return $this->currentPosition;
}
/**
* Returns the byte offset of the current position within the line.
*
* For single-byte lines this is identical to getPosition(). For multibyte
* lines the offset comes from the lazily-built character->byte map, which
* makes this an amortized-O(1) lookup rather than O(position) per call.
*/
public function getBytePosition(): int
{
if (! $this->isMultibyte) {
return $this->currentPosition;
}
return $this->byteOffset($this->currentPosition);
}
public function getPreviousText(): string
{
if ($this->isMultibyte) {
return \mb_substr($this->line, $this->previousPosition, $this->currentPosition - $this->previousPosition, 'UTF-8');
$startByte = $this->byteOffset($this->previousPosition);
return \substr($this->line, $startByte, $this->byteOffset($this->currentPosition) - $startByte);
}
return \substr($this->line, $this->previousPosition, $this->currentPosition - $this->previousPosition);
@ -477,7 +673,23 @@ class Cursor
public function getSubstring(int $start, ?int $length = null): string
{
if ($this->isMultibyte) {
return \mb_substr($this->line, $start, $length, 'UTF-8');
// Negative offsets/lengths are rare (and never used internally); defer to mb_substr
// so its exact semantics are preserved.
if ($start < 0 || ($length !== null && $length < 0)) {
return \mb_substr($this->line, $start, $length, 'UTF-8');
}
if ($start >= $this->length) {
return '';
}
$startByte = $this->byteOffset($start);
if ($length === null) {
return \substr($this->line, $startByte);
}
return \substr($this->line, $startByte, $this->byteOffset($start + \min($length, $this->length - $start)) - $startByte);
}
if ($length !== null) {

View file

@ -78,7 +78,6 @@ final class InlineParserEngine implements InlineParserEngineInterface
// We're now at a potential start - see which of the current parsers can handle it
$parsed = false;
foreach ($parsers as [$parser, $matches]) {
\assert($parser instanceof InlineParserInterface);
if ($parser->parse($inlineParserContext->withMatches($matches))) {
// A parser has successfully handled the text at the given position; don't consider any others at this position
$parsed = true;
@ -149,10 +148,16 @@ final class InlineParserEngine implements InlineParserEngineInterface
}
// For each part that matched...
$lastByteOffset = 0;
$lastCharOffset = 0;
foreach ($matches as $match) {
if ($isMultibyte) {
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying
$offset = \mb_strlen(\substr($contents, 0, $match[0][1]), 'UTF-8');
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying.
// Matches are returned in ascending order, so convert incrementally from the previous position
// instead of re-scanning from the start of the line for every match (which would be quadratic).
$lastCharOffset += \mb_strlen(\substr($contents, $lastByteOffset, $match[0][1] - $lastByteOffset), 'UTF-8');
$lastByteOffset = $match[0][1];
$offset = $lastCharOffset;
} else {
$offset = \intval($match[0][1]);
}

View file

@ -111,7 +111,7 @@ final class HtmlElement implements \Stringable
*/
public function setContents($contents): self
{
$this->contents = $contents ?? ''; // @phpstan-ignore-line
$this->contents = $contents ?? '';
return $this;
}

View file

@ -66,7 +66,7 @@ final class RegexHelper
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
public const REGEX_PUNCTUATION = '/^[\p{P}\p{S}]/u';
public const REGEX_UNSAFE_PROTOCOL = '/^javascript:|vbscript:|file:|data:/i';
public const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
public const REGEX_SAFE_DATA_PROTOCOL = '/^data:image\/(?:png|gif|jpeg|webp)/i';
public const REGEX_NON_SPACE = '/[^ \t\f\v\r\n]/';
@ -238,6 +238,11 @@ final class RegexHelper
*/
public static function isLinkPotentiallyUnsafe(string $url): bool
{
// Browsers discard these bytes before resolving the scheme (see the WHATWG URL Standard's
// "basic URL parser", steps 1 and 3), so `java<TAB>script:` and `<0x01>javascript:` reach
// the user as `javascript:` and must be treated as such here too
$url = \ltrim(\str_replace(["\t", "\n", "\r"], '', $url), "\x00..\x20");
return \preg_match(self::REGEX_UNSAFE_PROTOCOL, $url) !== 0 && \preg_match(self::REGEX_SAFE_DATA_PROTOCOL, $url) === 0;
}
}

View file

@ -36,6 +36,10 @@ final class XmlRenderer implements DocumentRendererInterface
{
$this->environment->dispatch(new DocumentPreRenderEvent($document, 'xml'));
// Indentation is purely cosmetic, so it's capped to keep the output size linear
// (rather than quadratic) with respect to the depth of the document.
$maxIndent = $this->getMaxIndentationLevel();
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$indent = 0;
@ -52,7 +56,7 @@ final class XmlRenderer implements DocumentRendererInterface
if ($event->isEntering()) {
$attrs = $renderer->getXmlAttributes($node);
$xml .= "\n" . \str_repeat(self::INDENTATION, $indent);
$xml .= "\n" . \str_repeat(self::INDENTATION, \min($indent, $maxIndent));
$xml .= self::tag($tagName, $attrs, $selfClosing);
if ($node instanceof StringContainerInterface) {
@ -68,7 +72,7 @@ final class XmlRenderer implements DocumentRendererInterface
}
} elseif (! $closeImmediately) {
$indent--;
$xml .= "\n" . \str_repeat(self::INDENTATION, $indent);
$xml .= "\n" . \str_repeat(self::INDENTATION, \min($indent, $maxIndent));
$xml .= self::tag('/' . $tagName);
}
}
@ -76,10 +80,15 @@ final class XmlRenderer implements DocumentRendererInterface
return new RenderedContent($document, $xml . "\n");
}
private function getMaxIndentationLevel(): int
{
return $this->environment->getConfiguration()->get('xml/max_indentation_level');
}
/**
* @param array<string, string|int|float|bool> $attrs
*/
private static function tag(string $name, array $attrs = [], bool $selfClosing = \false): string
private static function tag(string $name, array $attrs = [], bool $selfClosing = false): string
{
$result = '<' . $name;
foreach ($attrs as $key => $value) {
@ -112,7 +121,6 @@ final class XmlRenderer implements DocumentRendererInterface
return $value ? 'true' : 'false';
}
// @phpstan-ignore-next-line
throw new InvalidArgumentException('$value must be a string, int, float, or bool');
}

View file

@ -30,7 +30,7 @@
"nette/schema": "<1.2.2"
},
"suggest": {
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-mbstring": "to use Strings::lower() etc...",

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function array_slice, array_splice, count, is_int;
use function array_splice, array_unshift, count, is_int;
/**
@ -123,8 +123,8 @@ class ArrayList implements \ArrayAccess, \Countable, \IteratorAggregate
*/
public function prepend(mixed $value): void
{
$first = array_slice($this->list, 0, 1);
$this->offsetSet(0, $value);
array_splice($this->list, 1, 0, $first);
// route the value through offsetSet() first so a validation added in a subclass isn't bypassed
$this->offsetSet(null, $value);
array_unshift($this->list, ...array_splice($this->list, -1));
}
}

View file

@ -304,7 +304,7 @@ class Arrays
{
$parts = is_array($path)
? $path
: preg_split('#(\[\]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
: preg_split('#(\[]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if (!$parts || $parts === ['->'] || $parts[0] === '=' || $parts[0] === '|') {
throw new Nette\InvalidArgumentException("Invalid path '" . (is_array($path) ? implode('', $path) : $path) . "'.");

View file

@ -0,0 +1,187 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use function array_merge, checkdate, implode, is_int, is_string, preg_match, preg_replace_callback, sprintf, trim;
/**
* Extends PHP's DateTimeImmutable with strict validation and additional factory methods.
* Invalid dates and times are rejected with an exception instead of being silently adjusted.
* All modifications return a new instance, the original object never changes.
*/
class DateTimeImmutable extends \DateTimeImmutable implements \JsonSerializable
{
/** matches relative sub-day parts (minutes, seconds, ...) that must be applied in UTC to be DST-safe */
private const RelativePattern = '/[+-]?\s*\d+\s+((microsecond|millisecond|[mµu]sec)s?|[mµ]s|sec(ond)?s?|min(ute)?s?|hours?)(\s+ago)?\b/iu';
/**
* Creates a DateTimeImmutable object from a string, UNIX timestamp, or other DateTimeInterface object.
* @throws \Exception if the date and time are not valid.
*/
public static function from(string|int|\DateTimeInterface|null $time): static
{
if ($time instanceof \DateTimeInterface) {
return static::createFromInterface($time);
} elseif (is_int($time)) {
return (new static)->setTimestamp($time);
} else { // textual or null
return new static((string) $time);
}
}
/**
* Creates DateTimeImmutable object.
* @throws \Exception if the date and time are not valid.
*/
public static function fromParts(
int $year,
int $month,
int $day,
int $hour = 0,
int $minute = 0,
float $second = 0.0,
): static
{
$sec = (int) floor($second);
return (new static)
->setDate($year, $month, $day)
->setTime($hour, $minute, $sec, (int) round(($second - $sec) * 1e6));
}
/**
* Returns a new DateTimeImmutable object formatted according to the specified format.
*/
public static function createFromFormat(
string $format,
string $datetime,
string|\DateTimeZone|null $timezone = null,
): static|false
{
if (is_string($timezone)) {
$timezone = new \DateTimeZone($timezone);
}
$date = parent::createFromFormat($format, $datetime, $timezone);
return $date ? static::from($date) : false;
}
public function __construct(string $datetime = 'now', ?\DateTimeZone $timezone = null)
{
if (preg_match(self::RelativePattern, $datetime)) {
// sub-day relative parts must be applied in UTC, which cannot happen inside a constructor => resolve & re-parse
$result = self::resolve(null, $datetime, $timezone);
parent::__construct($result->format('Y-m-d H:i:s.u'), $result->getTimezone());
} else {
parent::__construct($datetime, $timezone);
self::handleErrors($datetime);
}
}
public function modify(string $modifier): static
{
return static::createFromInterface(self::resolve($this, $modifier, null));
}
public function setDate(int $year, int $month, int $day): static
{
if (!checkdate($month, $day, $year)) {
throw new \Exception(sprintf('The date %04d-%02d-%02d is not valid.', $year, $month, $day));
}
return parent::setDate($year, $month, $day);
}
public function setTime(int $hour, int $minute, int $second = 0, int $microsecond = 0): static
{
if (
$hour < 0 || $hour > 23
|| $minute < 0 || $minute > 59
|| $second < 0 || $second >= 60
|| $microsecond < 0 || $microsecond >= 1_000_000
) {
throw new \Exception(sprintf('The time %02d:%02d:%08.5F is not valid.', $hour, $minute, $second + $microsecond / 1_000_000));
}
return parent::setTime($hour, $minute, $second, $microsecond);
}
/**
* Splits the input into absolute and relative parts and returns the resulting instant. Relative sub-day
* parts are applied in UTC so that crossing a DST boundary does not shift the result.
*/
private static function resolve(?\DateTimeInterface $base, string $input, ?\DateTimeZone $timezone): \DateTimeImmutable
{
$relPart = '';
$absPart = preg_replace_callback(
self::RelativePattern,
function ($m) use (&$relPart) {
$relPart .= $m[0] . ' ';
return '';
},
$input,
);
if ($base === null) {
$result = new \DateTimeImmutable($absPart, $timezone);
self::handleErrors($input);
} else {
$result = \DateTimeImmutable::createFromInterface($base);
if (trim($absPart) !== '') {
$modified = @$result->modify($absPart); // @ - on PHP 8.2 an invalid modifier emits a warning and returns false instead of throwing; handleErrors() turns it into an exception
self::handleErrors($input);
$result = $modified ?: $result;
}
}
if ($relPart !== '') {
$timezone ??= $result->getTimezone();
$result = $result
->setTimezone(new \DateTimeZone('UTC'))
->modify($relPart)
->setTimezone($timezone);
self::handleErrors($input);
}
return $result;
}
/**
* Returns JSON representation in ISO 8601 (used by JavaScript).
*/
public function jsonSerialize(): string
{
return $this->format('c');
}
/**
* Returns the date and time in the format 'Y-m-d H:i:s'.
*/
public function __toString(): string
{
return $this->format('Y-m-d H:i:s');
}
private static function handleErrors(string $value): void
{
$errors = self::getLastErrors();
$errors = array_merge($errors['errors'] ?? [], $errors['warnings'] ?? []);
if ($errors) {
throw new \Exception(implode(', ', $errors) . " '$value'");
}
}
}

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function array_pop, chmod, decoct, dirname, end, fclose, file_exists, file_get_contents, file_put_contents, fopen, implode, is_dir, is_file, is_link, mkdir, preg_match, preg_split, realpath, rename, rmdir, rtrim, sprintf, str_replace, stream_copy_to_stream, stream_is_local, strtr;
use function array_pop, chmod, decoct, dirname, end, fclose, file_exists, file_get_contents, file_put_contents, fopen, implode, is_dir, is_file, is_link, mkdir, preg_match, preg_split, realpath, rename, rmdir, rtrim, sprintf, str_replace, stream_copy_to_stream, stream_is_local, strtr, uniqid, unlink, usleep;
use const DIRECTORY_SEPARATOR;
@ -233,6 +233,36 @@ final class FileSystem
}
/**
* Writes the string to a file atomically: the content is written to a temporary file, which then replaces
* the target, so a concurrent reader never sees the file partially written or truncated.
* Creates the parent directory if it does not exist. Pass null as $mode to skip chmod.
* @throws Nette\IOException on error occurred
*/
public static function writeAtomic(string $file, string $content, ?int $mode = 0o666): void
{
$file = realpath($file) ?: $file; // writes through a symlink to its target, as write() does
$tmp = $file . '.' . uniqid('', more_entropy: true) . '.tmp';
try {
static::write($tmp, $content, $mode);
// plain rename() is atomic; static::rename() must not be used here, it deletes the target first
for ($i = 0; !@rename($tmp, $file); $i++) { // @ is escalated to exception
if (!Helpers::IsWindows || $i >= 20) {
throw new Nette\IOException(sprintf(
"Unable to write file '%s'. %s",
self::normalizePath($file),
Helpers::getLastError(),
));
}
usleep(5_000); // on Windows, rename fails while the target is open in another process
}
} catch (\Throwable $e) {
@unlink($tmp);
throw $e;
}
}
/**
* Sets file permissions to `$fileMode` or directory permissions to `$dirMode`.
* Recursively traverses and sets permissions on the entire contents of the directory as well.

View file

@ -8,8 +8,8 @@
namespace Nette\Utils;
use Nette;
use function array_merge, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_ends_with, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, usort;
use const GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
use function array_filter, array_merge, array_values, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, trigger_error, usort;
use const DIRECTORY_SEPARATOR, E_USER_DEPRECATED, GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
/**
@ -47,18 +47,19 @@ class Finder implements \IteratorAggregate
/**
* Begins search for files and directories matching mask.
* Begins search for files and directories matching mask. The ** wildcard searches recursively; a trailing slash limits the mask to directories.
* @param string|list<string> $masks
*/
public static function find(string|array $masks = ['*']): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
return (new static)->addMask($masks, 'dir')->addMask($masks, 'file');
$files = array_filter($masks, fn(string $mask): bool => !self::hasTrailingSeparator($mask)); // trailing slash means directories only
return (new static)->addMask($masks, 'dir')->addMask(array_values($files), 'file');
}
/**
* Begins search for files matching mask.
* Begins search for files matching mask. The ** wildcard searches recursively.
* @param string|list<string> $masks
*/
public static function findFiles(string|array $masks = ['*']): static
@ -69,7 +70,7 @@ class Finder implements \IteratorAggregate
/**
* Begins search for directories matching mask.
* Begins search for directories matching mask. The ** wildcard searches recursively.
* @param string|list<string> $masks
*/
public static function findDirectories(string|array $masks = ['*']): static
@ -103,24 +104,34 @@ class Finder implements \IteratorAggregate
private function addMask(array $masks, string $mode): static
{
foreach ($masks as $mask) {
$mask = FileSystem::unixSlashes($mask);
$orig = $mask;
if ($mode === 'dir') {
$mask = rtrim($mask, '/');
$mask = rtrim($mask, '/\\');
}
if ($mask === '' || ($mode === 'file' && str_ends_with($mask, '/'))) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
if ($mask === '' || ($mode === 'file' && self::hasTrailingSeparator($mask))) {
throw new Nette\InvalidArgumentException("Invalid mask '$orig'");
}
if (str_starts_with($mask, '**/')) {
$mask = substr($mask, 3);
}
$this->find[] = [$mask, $mode];
$this->find[] = [self::expandGlobStar($mask), $mode];
}
return $this;
}
private static function hasTrailingSeparator(string $mask): bool
{
return ($last = substr($mask, -1)) === '/' || $last === '\\';
}
// Expands a ** that is not followed by a slash into **/*, so that e.g. "test/**" and "**.c" search recursively.
private static function expandGlobStar(string $mask): string
{
return preg_replace('~(?<=^|[/\\\])\*\*(?![/\\\])~', '**/*', $mask);
}
/**
* Searches in the given directories. Wildcards are allowed.
* Searches in the given directories. Wildcards * and ? are allowed; unlike in masks, [ and ] are taken literally.
* @param string|list<string> $paths
*/
public function in(string|array $paths): static
@ -132,13 +143,13 @@ class Finder implements \IteratorAggregate
/**
* Searches recursively from the given directories. Wildcards are allowed.
* Searches recursively from the given directories. Wildcards * and ? are allowed; unlike in masks, [ and ] are taken literally.
* @param string|list<string> $paths
*/
public function from(string|array $paths): static
{
$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
$this->addLocation($paths, '/**');
$this->addLocation($paths, DIRECTORY_SEPARATOR . '**');
return $this;
}
@ -150,7 +161,7 @@ class Finder implements \IteratorAggregate
if ($path === '') {
throw new Nette\InvalidArgumentException("Invalid directory '$path'");
}
$path = rtrim(FileSystem::unixSlashes($path), '/');
$path = rtrim($path, '/\\');
$this->in[] = $path . $ext;
}
}
@ -216,24 +227,29 @@ class Finder implements \IteratorAggregate
/**
* Skips entries that matches the given masks relative to the ones defined with the in() or from() methods.
* Skips entries that match the given masks, using the same grammar as find() masks, relative to the directories from in() or from().
* A trailing slash excludes directories only; a trailing /* or /** excludes the contents while keeping the directory itself.
* @param string|list<string> $masks
*/
public function exclude(string|array $masks): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
foreach ($masks as $mask) {
$orig = $mask;
$mask = FileSystem::unixSlashes($mask);
if (!preg_match('~^/?(\*\*/)?(.+)(/\*\*|/\*|/|)$~D', $mask, $m)) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
if (FileSystem::isAbsolute($mask) || $mask === '..' || str_starts_with($mask, '../')) {
trigger_error("Absolute or ../ mask '$orig' in exclude() is deprecated and will change meaning, use a mask relative to the searched directory.", E_USER_DEPRECATED);
}
if (!preg_match('~^/?(\*\*/)?(.+?)(/\*\*|/\*|/|)$~D', $mask, $m)) {
throw new Nette\InvalidArgumentException("Invalid mask '$orig'");
}
$end = $m[3];
$re = $this->buildPattern($m[2]);
$re = $this->buildPattern(self::expandGlobStar($m[2]));
$filter = fn(FileInfo $file): bool => ($end && !$file->isDir())
|| !preg_match($re, FileSystem::unixSlashes($file->getRelativePathname()));
$this->descentFilter($filter);
if ($end !== '/*') {
if ($end === '' || $end === '/') {
$this->filter($filter);
}
}
@ -340,7 +356,6 @@ class Finder implements \IteratorAggregate
if ($item instanceof self) {
yield from $item->getIterator();
} else {
$item = FileSystem::platformSlashes($item);
yield $item => new FileInfo($item);
}
}
@ -361,7 +376,7 @@ class Finder implements \IteratorAggregate
}
try {
$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);
$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME);
} catch (\UnexpectedValueException $e) {
if ($this->ignoreUnreadableDirs) {
return;
@ -370,7 +385,7 @@ class Finder implements \IteratorAggregate
}
}
$files = $this->convertToFiles($pathNames, implode('/', $subdirs), FileSystem::isAbsolute($dir));
$files = $this->convertToFiles($pathNames, implode(DIRECTORY_SEPARATOR, $subdirs), FileSystem::isAbsolute($dir));
if ($this->sort) {
$files = iterator_to_array($files);
@ -417,9 +432,8 @@ class Finder implements \IteratorAggregate
{
foreach ($pathNames as $pathName) {
if (!$absolute) {
$pathName = preg_replace('~\.?/~A', '', $pathName);
$pathName = preg_replace('~\.?[\\\/]~A', '', $pathName);
}
$pathName = FileSystem::platformSlashes($pathName);
yield new FileInfo($pathName, $relativePath);
}
}
@ -457,7 +471,7 @@ class Finder implements \IteratorAggregate
} else {
foreach ($this->in ?: ['.'] as $in) {
$in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()
$splits[] = self::splitRecursivePart($in . '/' . $mask);
$splits[] = self::splitRecursivePart($in . DIRECTORY_SEPARATOR . $mask);
}
}
@ -488,11 +502,13 @@ class Finder implements \IteratorAggregate
*/
private static function splitRecursivePart(string $path): array
{
$a = strrpos($path, '/');
$parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);
$pos = strrpos(strtr($path, '\\', '/'), '/');
$dir = $pos === false ? '' : substr($path, 0, $pos + 1);
$file = $pos === false ? $path : substr($path, $pos + 1);
$parts = preg_split('~(?<=^|[\\\/])\*\*($|[\\\/])~', $dir, 2);
return isset($parts[1])
? [$parts[0], $parts[1] . substr($path, $a + 1), true]
: [$parts[0], substr($path, $a + 1), false];
? [$parts[0], $parts[1] . $file, true]
: [$parts[0], $file, false];
}
@ -501,6 +517,7 @@ class Finder implements \IteratorAggregate
*/
private function buildPattern(string $mask): string
{
$mask = FileSystem::unixSlashes($mask);
if ($mask === '*') {
return '##';
} elseif (str_starts_with($mask, './')) {

View file

@ -8,8 +8,8 @@
namespace Nette\Utils;
use Nette\HtmlStringable;
use function array_merge, array_splice, count, explode, func_num_args, html_entity_decode, htmlspecialchars, http_build_query, implode, is_array, is_bool, is_float, is_object, is_string, json_encode, max, number_format, rtrim, str_contains, str_repeat, str_replace, strip_tags, strncmp, strpbrk, substr;
use const ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
use function array_merge, array_splice, count, explode, func_num_args, html_entity_decode, htmlspecialchars, http_build_query, implode, is_array, is_bool, is_float, is_object, is_string, json_encode, max, number_format, rtrim, str_contains, str_repeat, str_replace, strip_tags, strncmp, strpbrk, substr, trigger_error, ucfirst;
use const E_USER_DEPRECATED, ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
/**
@ -229,6 +229,9 @@ use const ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
* @method self width(?int $val)
* @method self wrap(?string $val)
*
* @method static static text(mixed $text)
* @method static static html(mixed $html)
*
* @implements \IteratorAggregate<int, self|string>
* @implements \ArrayAccess<int, self|string>
*/
@ -280,8 +283,19 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Creates a nameless element (fragment) containing the given children.
* Everything except HtmlStringable is escaped; use Html::html() for raw HTML. Nulls are skipped.
*/
public static function fragment(HtmlStringable|\Stringable|string|int|null ...$children): static
{
return (new static)->add(...$children);
}
/**
* Returns an object representing HTML text.
* @deprecated use Html::html()
*/
public static function fromHtml(string $html): static
{
@ -291,6 +305,7 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
/**
* Returns an object representing plain text.
* @deprecated use Html::text()
*/
public static function fromText(string $text): static
{
@ -473,6 +488,10 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
*/
final public function __call(string $m, array $args): mixed
{
if ($m === 'text' || $m === 'html') {
trigger_error("Method \$el->$m() is deprecated, use set" . ucfirst($m) . "() for content or setAttribute() for the '$m' attribute; Html::$m() is a static factory.", E_USER_DEPRECATED);
}
$p = substr($m, 0, 3);
if ($p === 'get' || $p === 'set' || $p === 'add') {
$m = substr($m, 3);
@ -498,6 +517,20 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Creates element with escaped text (Html::text()) or raw HTML (Html::html()) content.
* @param mixed[] $args
*/
final public static function __callStatic(string $name, array $args): static
{
return match ($name) {
'text' => (new static)->setText(...$args),
'html' => (new static)->setHtml(...$args),
default => ObjectHelpers::strictStaticCall(static::class, $name),
};
}
/**
* Special setter for element's attribute.
* @param array<string, mixed> $query
@ -575,6 +608,21 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Appends the given children. Everything except HtmlStringable is escaped; use Html::html() for raw HTML. Nulls are skipped.
*/
public function add(HtmlStringable|\Stringable|string|int|null ...$children): static
{
foreach ($children as $child) {
if ($child !== null) {
$this->addText($child);
}
}
return $this;
}
/**
* Adds new element's child.
*/
@ -813,8 +861,8 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
$q = str_contains($value, '"') ? "'" : '"';
$s .= ' ' . $key . '=' . $q
. str_replace(
['&', $q, '<'],
['&amp;', $q === '"' ? '&quot;' : '&#39;', '<'],
['&', $q],
['&amp;', $q === '"' ? '&quot;' : '&#39;'],
$value,
)
. (str_contains($value, '`') && strpbrk($value, ' <>"\'') === false ? ' ' : '')

View file

@ -327,7 +327,7 @@ class Image
ImageType::PNG => IMG_PNG,
ImageType::GIF => IMG_GIF,
ImageType::WEBP => IMG_WEBP,
ImageType::AVIF => 256, // IMG_AVIF,
ImageType::AVIF => IMG_AVIF,
ImageType::BMP => IMG_BMP,
default => 0,
});
@ -347,7 +347,7 @@ class Image
$flag & IMG_JPG ? ImageType::JPEG : null,
$flag & IMG_PNG ? ImageType::PNG : null,
$flag & IMG_WEBP ? ImageType::WEBP : null,
$flag & 256 ? ImageType::AVIF : null, // IMG_AVIF
$flag & IMG_AVIF ? ImageType::AVIF : null,
$flag & IMG_BMP ? ImageType::BMP : null,
]);
}

View file

@ -195,7 +195,7 @@ final class Iterables
return new class ($factory(...)) implements \IteratorAggregate {
public function __construct(
/** @var \Closure(): iterable<mixed, mixed> */
private \Closure $factory,
private readonly \Closure $factory,
) {
}

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function defined, is_int, json_decode, json_encode, json_last_error, json_last_error_msg;
use function is_int, json_decode, json_encode, json_last_error, json_last_error_msg;
use const JSON_BIGINT_AS_STRING, JSON_FORCE_OBJECT, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT, JSON_HEX_TAG, JSON_OBJECT_AS_ARRAY, JSON_PRESERVE_ZERO_FRACTION, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE;
@ -51,8 +51,7 @@ final class Json
| ($htmlSafe ? JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG : 0);
}
$flags |= JSON_UNESCAPED_SLASHES
| (defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0); // since PHP 5.6.6 & PECL JSON-C 1.3.7
$flags |= JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION;
$json = json_encode($value, $flags);
if ($error = json_last_error()) {

View file

@ -220,7 +220,7 @@ final class ObjectHelpers
if ($rp->isPublic() && !$rp->isStatic()) {
$prop = $name >= 'onA' && $name < 'on_' ? 'event' : true;
}
} catch (\ReflectionException $e) {
} catch (\ReflectionException) {
}
}

View file

@ -8,6 +8,8 @@
namespace Nette\Utils;
use Nette;
use function is_resource, is_string, strlen;
use const PHP_VERSION_ID;
/**
@ -41,7 +43,11 @@ final class Process
/** @var array<int, true> Output IDs whose target resource was supplied by the caller and must not be closed here. */
private array $callerOutputs = [];
/** @var array<int, true> Output IDs whose pipe is backed by a temporary file (Windows < 8.5 workaround). */
private array $fileBackedOutputs = [];
private float $startTime;
private bool $detached = false;
/**
@ -113,7 +119,7 @@ final class Process
mixed $stdin,
mixed $stdout,
mixed $stderr,
private ?float $timeout,
private readonly ?float $timeout,
) {
$descriptors = [
self::StdIn => $this->createInputDescriptor($stdin),
@ -145,11 +151,32 @@ final class Process
public function __destruct()
{
if ($this->detached) {
return;
}
$this->outputBuffers = [];
$this->terminate();
}
/**
* Detaches the process: it keeps running in the background and is not terminated when the object
* is destroyed. STDIN and output pipes are closed, so the output must not be captured in memory
* (pass a file name, a resource or false as $stdout/$stderr). Only the destructor behavior changes:
* wait() and getExitCode() still block ($timeout still applies) and terminate() still kills it.
* On POSIX, a detached child that exits early stays a zombie until the script ends.
*/
public function detach(): void
{
if ($this->outputBuffers !== []) {
throw new Nette\InvalidStateException('Cannot detach process: its output is captured in memory, pass a file name, a resource or false as $stdout/$stderr.');
}
$this->detached = true;
$this->closeStdInput();
$this->closeOutputPipes();
}
/**
* Checks if the process is currently running.
*/
@ -189,12 +216,12 @@ final class Process
/**
* Reads any new data from the captured pipes into the buffers, so a process producing more output
* than the OS pipe buffer holds does not block. (On Windows the captured output is a file and never blocks.)
* than the OS pipe buffer holds does not block. With $final (process already finished) reads to EOF.
*/
private function drainPipes(): void
private function drainPipes(bool $final = false): void
{
foreach ([self::StdOut, self::StdErr] as $id) {
$this->readFromPipe($id);
$this->readFromPipe($id, $final);
}
}
@ -397,16 +424,39 @@ final class Process
* Reads any new data from the specified pipe and appends it to the buffer. Does nothing if the output
* is not captured or the pipe is already closed (or handed over to another process).
*/
private function readFromPipe(int $id): void
private function readFromPipe(int $id, bool $final = false): void
{
if (!isset($this->outputBuffers[$id]) || !is_resource($this->outputPipes[$id] ?? null)) {
$pipe = $this->outputPipes[$id] ?? null;
if (!isset($this->outputBuffers[$id]) || !is_resource($pipe)) {
return;
} elseif (Helpers::IsWindows) {
fseek($this->outputPipes[$id], strlen($this->outputBuffers[$id]));
} elseif (isset($this->fileBackedOutputs[$id])) {
// Windows < 8.5: captured output is a temporary file; read whatever was appended since last time
fseek($pipe, strlen($this->outputBuffers[$id]));
$this->outputBuffers[$id] .= stream_get_contents($pipe);
} elseif ($final) {
// the process has finished, everything is buffered in the pipe; non-blocking read on POSIX
// (cannot hang on a leaked descendant still holding the write end), blocking read to EOF on
// Windows, where non-blocking mode does not work and stream_select() may miss buffered data
stream_set_blocking($pipe, Helpers::IsWindows);
$this->outputBuffers[$id] .= stream_get_contents($pipe);
} else {
stream_set_blocking($this->outputPipes[$id], false);
// non-blocking drain: stream_set_blocking(false) works on POSIX only, so reads are also
// guarded by stream_select(), which works on Windows pipes since PHP 8.5 (PeekNamedPipe fix)
stream_set_blocking($pipe, false);
$read = [$pipe];
$write = $except = [];
while (@stream_select($read, $write, $except, 0, 0) > 0) {
$chunk = fread($pipe, 8192);
if ($chunk === false || $chunk === '') {
break;
}
$this->outputBuffers[$id] .= $chunk;
$read = [$pipe];
}
}
$this->outputBuffers[$id] .= stream_get_contents($this->outputPipes[$id]);
}
@ -470,9 +520,12 @@ final class Process
} elseif ($output === null) {
$this->outputBuffers[$id] = '';
$this->outputBufferOffsets[$id] = 0;
// On Windows anonymous pipes are blocking and cannot be polled without freezing the process,
// so captured output is backed by a temporary file that can be read non-blockingly (needed for timeouts).
return Helpers::IsWindows ? tmpfile() : ['pipe', 'w'];
if (Helpers::IsWindows && PHP_VERSION_ID < 80500) {
// Windows < 8.5: stream_select() doesn't work on pipes, capture into a temp file that reads non-blockingly
$this->fileBackedOutputs[$id] = true;
return tmpfile();
}
return ['pipe', 'w'];
} else {
throw new Nette\InvalidArgumentException('Output must be string, resource, bool or null, ' . get_debug_type($output) . ' given.');
@ -485,7 +538,7 @@ final class Process
*/
private function close(): void
{
$this->drainPipes();
$this->drainPipes(final: true);
$this->closeStdInput();
$this->closeOutputPipes();
proc_close($this->process);
@ -494,7 +547,7 @@ final class Process
/**
* Closes the output pipes that this class opened; resources supplied by the caller are left untouched.
* (The temporary file backing captured output on Windows is removed by fclose() itself.)
* (The temporary file backing captured output on Windows < 8.5 is removed by fclose() itself.)
*/
private function closeOutputPipes(): void
{

View file

@ -230,7 +230,7 @@ final class Reflection
try {
$tokens = \PhpToken::tokenize($code, TOKEN_PARSE);
} catch (\ParseError $e) {
trigger_error($e->getMessage(), E_USER_NOTICE);
trigger_error($e->getMessage());
$tokens = [];
}

View file

@ -9,7 +9,7 @@ namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function array_keys, array_map, array_shift, array_values, bin2hex, class_exists, defined, extension_loaded, function_exists, htmlspecialchars, htmlspecialchars_decode, iconv, iconv_strlen, iconv_substr, implode, in_array, is_array, is_callable, is_int, is_object, is_string, key, max, mb_convert_case, mb_strlen, mb_strtolower, mb_strtoupper, mb_substr, pack, preg_last_error, preg_last_error_msg, preg_quote, preg_replace, str_contains, str_ends_with, str_repeat, str_replace, str_starts_with, strlen, strpos, strrev, strrpos, strtolower, strtoupper, strtr, substr, trim, unpack, utf8_decode;
use function array_keys, array_map, array_shift, array_values, bin2hex, class_exists, defined, extension_loaded, function_exists, htmlspecialchars, htmlspecialchars_decode, iconv, iconv_strlen, iconv_substr, implode, in_array, is_array, is_callable, is_int, is_object, is_string, key, max, mb_convert_case, mb_strlen, mb_strtolower, mb_strtoupper, mb_substr, pack, preg_last_error, preg_last_error_msg, preg_quote, preg_replace, str_contains, str_ends_with, str_repeat, str_replace, str_starts_with, strlen, strpos, strrev, strrpos, strtolower, strtoupper, strtr, substr, trim, unpack;
use const ENT_IGNORE, ENT_NOQUOTES, ICONV_IMPL, MB_CASE_TITLE, PHP_EOL, PREG_OFFSET_CAPTURE, PREG_PATTERN_ORDER, PREG_SET_ORDER, PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_NO_EMPTY, PREG_SPLIT_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL;
@ -333,8 +333,8 @@ class Strings
public static function compare(string $left, string $right, ?int $length = null): bool
{
if (class_exists('Normalizer', autoload: false)) {
$left = \Normalizer::normalize($left, \Normalizer::FORM_D); // form NFD is faster
$right = \Normalizer::normalize($right, \Normalizer::FORM_D); // form NFD is faster
$left = \Normalizer::normalize($left, \Normalizer::FORM_D) ?: $left; // form NFD is faster, false on invalid UTF-8
$right = \Normalizer::normalize($right, \Normalizer::FORM_D) ?: $right; // form NFD is faster, false on invalid UTF-8
}
if ($length < 0) {
@ -385,7 +385,7 @@ class Strings
return match (true) {
extension_loaded('mbstring') => (int) mb_strlen($s, 'UTF-8'),
extension_loaded('iconv') => (int) iconv_strlen($s, 'UTF-8'),
default => strlen(@utf8_decode($s)), // deprecated
default => strlen((string) preg_replace('#[\x80-\xBF]#', '', $s)), // strips UTF-8 continuation bytes
};
}
@ -396,7 +396,7 @@ class Strings
public static function trim(string $s, string $charlist = self::TrimCharacters): string
{
$charlist = preg_quote($charlist, '#');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du', '');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du');
}

View file

@ -337,14 +337,15 @@ class Validators
public static function isUrl(string $value): bool
{
$alpha = "a-z\x80-\xFF";
$octet = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'; // 0..255
return (bool) preg_match(<<<XX
(^(?n)
https?://(
(([-_0-9$alpha]+\\.)* # subdomain
[0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)? # domain
[$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain
|\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3} # IPv4
|\\[[0-9a-f:]{3,39}\\] # IPv6
|$octet(\\.$octet){3} # IPv4
|\\[[0-9a-f:]{3,39}] # IPv6
)(:\\d{1,5})? # port
(/\\S*)? # path
(\\?\\S*)? # query
@ -359,7 +360,7 @@ class Validators
*/
public static function isUri(string $value): bool
{
return (bool) preg_match('#^[a-z\d+\.-]+:\S+$#Di', $value);
return (bool) preg_match('#^[a-z\d+.-]+:\S+$#Di', $value);
}